Compare commits

...

36 Commits

Author SHA1 Message Date
github-actions[bot]
06e0285b9c release: v0.3.4 2025-12-12 13:26:05 +00:00
Srinivasa Babu B
64138ee88f Refactor AgentOverridesSchema to use object structure (#24) 2025-12-12 15:38:39 +09:00
Nguyen Quang Huy
359340de65 feat: add claude_code config object to toggle Claude Code compatibility features (#23) 2025-12-12 15:23:50 +09:00
github-actions[bot]
3eb88aa861 release: v0.3.3 2025-12-12 01:39:26 +00:00
YeonGyu-Kim
652f343c95 feat(agents): enhance librarian and explore prompts with parallel execution and evidence-based citations (#21)
* feat(agents): enhance librarian and explore prompts with parallel execution and evidence-based citations

Librarian agent enhancements:
- Add mandatory 5+ parallel tool execution requirement
- Add WebSearch integration for latest information
- Add repository cloning to /tmp for deep source analysis
- Require GitHub permalinks for all code citations
- Add evidence-based reasoning with specific code references
- Enhanced gh CLI usage with permalink construction

Explore agent enhancements:
- Add mandatory 3+ parallel tool execution requirement
- Extensive Git CLI integration for repository analysis
- Add git log, git blame, git diff commands for exploration
- Add parallel execution examples and best practices

* feat(agents): add LSP and AST-grep tools to librarian and explore prompts

Librarian agent:
- Added LSP tools section (lsp_hover, lsp_goto_definition, lsp_find_references, etc.)
- Added AST-grep section with pattern examples for structural code search
- Updated parallel execution examples to include LSP and AST-grep tools
- Added guidance on when to use AST-grep vs Grep vs LSP

Explore agent:
- Added LSP tools section for semantic code analysis
- Added AST-grep section with examples for TypeScript/React patterns
- Updated parallel execution examples to include 6 tools
- Added tool selection guidance for LSP and AST-grep

* fix(agents): remove explore agent references from librarian prompt

Subagents cannot call other agents, so replaced all Explore agent
references with direct tool usage (Glob, Grep, ast_grep_search).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-12 09:16:57 +09:00
YeonGyu-Kim
9ba41558de feat(config): add cross-platform user-level config support (#20) 2025-12-12 08:53:27 +09:00
YeonGyu-Kim
50727171a6 feat(agents): upgrade oracle model from GPT-5.1 to GPT-5.2 (#19) 2025-12-12 08:52:18 +09:00
github-actions[bot]
14ff86c547 release: v0.3.2 2025-12-11 23:31:22 +00:00
Nguyen Quang Huy
e4036185f0 fix: load config from user-level ~/.config/opencode/oh-my-opencode.json (#17) 2025-12-12 08:29:32 +09:00
YeonGyu-Kim
d34154bc68 feat(skill): align with opencode-skills approach
- Add Zod schema validation following Anthropic Agent Skills Spec v1.0
- Include basePath in skill output for path resolution
- Simplify tool description and output format
- Add validation error logging for invalid skills

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-11 10:14:20 +09:00
YeonGyu-Kim
9e00be91af feat(hooks): add directory README.md injector (#15)
Implements README.md injection similar to existing AGENTS.md injector.
Automatically injects README.md contents when reading files, searching
upward from file directory to project root.

Closes #14

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-11 10:13:04 +09:00
github-actions[bot]
40d4673201 release: v0.3.1 2025-12-10 14:44:44 +00:00
YeonGyu-Kim
cf33fc5da1 docs(readme): sync English version with Korean improvements on setup and configuration clarity 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
407786978a chore: remove test files (test-rule.yml, test.js) 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
15454f1d81 chore: remove test files and temporary notepad 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
56160d17f8 docs(readme.ko): improve clarity on setup configuration paths and MCP/LSP explanations 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
61bbbcb577 feat(hooks): integrate anthropic-auto-compact hook for automatic context summarization
Enables automatic session summarization when Anthropic token limits are exceeded.
The hook detects token limit errors and triggers compact operation on session idle.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-10 23:43:35 +09:00
YeonGyu-Kim
adabace02d improve(hooks): refine context window reminder message for better clarity and guidance 2025-12-10 15:45:45 +09:00
YeonGyu-Kim
41f93c9f8b docs(readme): add warning for LLM agents on oh-my-opencode.json setup and sync English tone with Korean version 2025-12-10 15:45:45 +09:00
YeonGyu-Kim
8102d178cb fix(hooks): fix TODO continuation abort handling with timer-based approach
Replace blocking await with non-blocking timer scheduling to handle race
condition between session.idle and session.error events. When ESC abort
occurs, session.error immediately cancels the pending timer, preventing
unwanted continuation prompts.

Changes:
- Add pendingTimers Map to track scheduled continuation checks
- Cancel timer on session.error (especially abort cases)
- Cancel timer on message.updated and session.deleted for cleanup
- Reduce delay to 200ms for faster response
- Maintain existing Set-based flag logic for compatibility

This fixes the issue where ESC abort would not prevent continuation
prompts due to event ordering (idle before error)
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
4f019f8fe5 fix(hooks): improve session recovery for empty content messages
- Extract message index from Anthropic error messages (messages.N format)
- Sort messages by time.created instead of id for accurate ordering
- Remove last message skip logic that prevented recovery
- Prioritize recovery targets: index-matched > failedMsg > all empty
- Add error logging for debugging recovery failures

Fixes issue where 'messages.83: all messages must have non-empty content' errors were not being recovered properly due to incorrect message ordering and overly restrictive filtering.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
7b19177c8a Revert "fix(hooks): improve TODO continuation race condition handling with state machine pattern"
This reverts commit e59b0be6cc380a3750e2d56c4c7ba553feef2c40.
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
e8f59cbbf8 fix(hooks): improve TODO continuation race condition handling with state machine pattern
- Replace multiple Set-based tracking with explicit SessionStatus state machine
- Implement setTimeout+clearTimeout pattern for robust race condition handling
- SessionStatus tracks: idle → continuation-sent or aborted states
- Increase grace period to 500ms to accommodate event ordering delays
- Add cleanupSession utility for proper resource cleanup

This addresses ESC abort not canceling continuation prompts when session.idle
arrives before session.error event, which can occur due to async event processing
in OpenCode plugin system
2025-12-10 15:45:45 +09:00
github-actions[bot]
2d23a81926 release: v0.3.0 2025-12-09 12:51:28 +00:00
YeonGyu-Kim
31cb8616c2 chore: bump version to 0.2.0
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 21:47:21 +09:00
YeonGyu-Kim
1932257f82 docs(readme): add comprehensive Claude Code compatibility documentation
- Add new 'Claude Code Compatibility' section to both README.md and README.ko.md
- Document hooks integration (PreToolUse, PostToolUse, UserPromptSubmit, Stop)
- Document configuration loaders (Command, Skill, Agent, MCP loaders)
- Document data storage (Todo management, Transcript logging)
- Simplify 'Other Features' section by moving loaders to new section
- Clean up temporary planning files in local-ignore/

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 21:47:01 +09:00
YeonGyu-Kim
5a793bb526 fix(hooks): align Claude Code hooks with opencode-cc-plugin reference
100% port verification via Oracle agent parallel checks:
- PreToolUse: recordToolUse(), isHookDisabled(), Object.assign(), error message
- PostToolUse: recordToolResult(), isHookDisabled(), permissionMode, title field
- Stop: isHookDisabled(), parentSessionId, error/interrupt state tracking
- UserPromptSubmit: interrupt checks, recordUserMessage(), log messages

All four hooks now match opencode-cc-plugin/src/plugin/*.ts exactly.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 19:00:01 +09:00
YeonGyu-Kim
2ec351d0d8 feat(hooks): implement UserPromptSubmit with chat.message hook and injectHookMessage
- Add chat.message handler to createClaudeCodeHooksHook factory
- Integrate executeUserPromptSubmitHooks() for user prompt processing
- Use injectHookMessage() for file system based message injection
- Add sessionFirstMessageProcessed tracking for title generation skip
- Register chat.message hook in plugin entry point

This completes 100% port of Claude Code hooks from opencode-cc-plugin.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:20:13 +09:00
YeonGyu-Kim
441fc1a219 feat(hooks): integrate Claude Code hooks with plugin system
- Create factory function createClaudeCodeHooksHook()
- Wire tool.execute.before → executePreToolUseHooks
- Wire tool.execute.after → executePostToolUseHooks
- Wire event (session.idle) → executeStopHooks
- Register hooks in src/index.ts
- Claude hooks execute first in handler chain

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:10:30 +09:00
YeonGyu-Kim
bd67419d1d feat(features): add hook message injector
- Port hook-message-injector from opencode-cc-plugin (4 files)
- constants.ts: XDG-based path definitions (MESSAGE_STORAGE, PART_STORAGE)
- types.ts: MessageMeta, OriginalMessageContext, TextPart interfaces
- injector.ts: injectHookMessage() implementation with message/part storage
- index.ts: Barrel export
- Self-contained module with no import path changes needed
- Preserves XDG_DATA_HOME environment variable support
- Preserves message fallback logic for incomplete originalMessage

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:02:45 +09:00
YeonGyu-Kim
dca98121ac feat(hooks): add UserPromptSubmit and Stop executors
- Port user-prompt-submit.ts from opencode-cc-plugin (118 lines)
- Port stop.ts from opencode-cc-plugin (119 lines)
- Preserve recursion prevention logic (<user-prompt-submit-hook> tags)
- Preserve inject_prompt support (message injection, stop prompt injection)
- Preserve stopHookActiveState management (per-session state)
- Import path adjustments: ../types → ./types, ../../config → ./plugin-config
- All exit code handling preserved (exitCode 2 → block, etc.)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:00:16 +09:00
YeonGyu-Kim
3fcfedcec0 feat(hooks): add PostToolUse hook executor
- Port post-tool-use.ts from opencode-cc-plugin (200 lines)
- Implement executePostToolUseHooks() with full transcript support
- Include temp file cleanup in finally block
- Preserve all exit code handling and output fields
- Update notepad.md with Task 5 completion log

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:55:06 +09:00
YeonGyu-Kim
530c4d63d5 feat(hooks): add PreToolUse hook executor
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:48:28 +09:00
YeonGyu-Kim
e0b43380cc feat(hooks): add Claude hooks config, transcript, and todo
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:41:39 +09:00
YeonGyu-Kim
a27cac96d5 feat(hooks): add Claude Code hooks type definitions
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:36:12 +09:00
YeonGyu-Kim
fef7f4ca03 feat(shared): add Claude hooks command executor and utilities
- Add snake-case.ts: objectToSnakeCase, objectToCamelCase utilities
- Add tool-name.ts: transformToolName with PascalCase conversion
- Add pattern-matcher.ts: findMatchingHooks for hook config matching
- Add hook-disabled.ts: isHookDisabled for hook config validation
- Add temporary stub types at src/hooks/claude-code-hooks/types.ts
- Export all new utilities from src/shared/index.ts

Stub types will be replaced with full implementation in Task 1.
Import paths adjusted from opencode-cc-plugin structure to oh-my-opencode.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:31:15 +09:00
53 changed files with 3263 additions and 869 deletions

View File

@@ -65,7 +65,7 @@ oh-my-opencode/
| Agent | Model | Purpose |
|-------|-------|---------|
| oracle | GPT-5.1 | Code review, strategic planning |
| oracle | GPT-5.2 | Code review, strategic planning |
| librarian | Claude Haiku | Documentation, example lookup |
| explore | Grok | File/codebase exploration |
| frontend-ui-ux-engineer | Gemini | UI generation |

View File

@@ -15,6 +15,7 @@
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
- [Grep](#grep)
- [내장 MCPs](#내장-mcps)
- [Claude Code 호환성](#claude-code-호환성)
- [기타 편의 기능](#기타-편의-기능)
- [설정](#설정)
- [작성자의 노트](#작성자의-노트)
@@ -105,7 +106,19 @@ opencode --version # 1.0.132 이상이어야 함
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
```
> **참고**: 모델 인증이 필요하면, `opencode` 실행 후 `/connect` 명령어를 사용하거나, tmux를 통해 `opencode auth login`을 안내하세요.
### 4단계: 인증정보 설정
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 다음의 내용을 진행주세요.
1. tmux 를 사용하여 셸 열기
2. opencode auth login > Provider Anthropic > Login method Claude Pro/Max > 사용자에게 oauth 화면에서 인증하라고 안내
3. 사용자에게 macOS 일 경우 VibeProxy, 아닐 경우 CLIProxyAPI 를 사용하여 OpenAI / Google 인증을 완료하라고 안내
### ⚠️ 주의
**사용자가 명시적으로 요청하지 않는 한, `oh-my-opencode.json` 설정 파일을 생성하거나 수정하지 마세요.**
플러그인은 설정 파일 없이도 기본값으로 완벽하게 동작합니다. 설정 파일은 사용자가 커스터마이징을 원할 때만 필요합니다.
</details>
@@ -153,6 +166,7 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
```
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
- **Directory README.md Injector**: 파일을 읽을 때 `README.md` 내용을 자동으로 주입합니다. AGENTS.md Injector와 동일하게 동작하며, 파일 디렉토리부터 프로젝트 루트까지 탐색합니다. LLM 에이전트에게 프로젝트 문서 컨텍스트를 제공합니다. 각 디렉토리의 README는 세션당 한 번만 주입됩니다.
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
@@ -160,7 +174,7 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
### Agents
- **oracle** (`openai/gpt-5.1`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.1의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **librarian** (`anthropic/claude-haiku-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Haiku의 빠른 속도, 적절한 지능, 훌륭한 도구 호출 능력, 저렴한 비용을 활용합니다. AmpCode 에서 영감을 받았습니다.
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
@@ -224,17 +238,98 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
}
```
### Claude Code 호환성
Oh My OpenCode는 Claude Code 설정과 완벽하게 호환됩니다. Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
#### 호환성 토글
특정 Claude Code 호환 기능을 비활성화하려면 `claude_code` 설정 객체를 사용하세요:
```json
{
"claude_code": {
"mcp": false,
"commands": false,
"skills": false,
"agents": false,
"hooks": false
}
}
```
| 토글 | `false`일 때 로딩 비활성화 경로 | 영향 받지 않음 |
|------|-------------------------------|---------------|
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 내장 MCP (context7, websearch_exa) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 내장 에이전트 (oracle, librarian 등) |
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
모든 토글은 기본값이 `true` (활성화)입니다. 완전한 Claude Code 호환성을 원하면 `claude_code` 객체를 생략하세요.
#### Hooks 통합
Claude Code의 `settings.json` 훅 시스템을 통해 커스텀 스크립트를 실행합니다. Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- `~/.claude/settings.json` (사용자)
- `./.claude/settings.json` (프로젝트)
- `./.claude/settings.local.json` (로컬, git-ignored)
지원되는 훅 이벤트:
- **PreToolUse**: 도구 실행 전에 실행. 차단하거나 도구 입력을 수정할 수 있습니다.
- **PostToolUse**: 도구 실행 후에 실행. 경고나 컨텍스트를 추가할 수 있습니다.
- **UserPromptSubmit**: 사용자가 프롬프트를 제출할 때 실행. 차단하거나 메시지를 주입할 수 있습니다.
- **Stop**: 세션이 유휴 상태가 될 때 실행. 후속 프롬프트를 주입할 수 있습니다.
`settings.json` 예시:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
}
]
}
}
```
#### 설정 로더
**Command Loader**: 4개 디렉토리에서 마크다운 기반 슬래시 명령어를 로드합니다:
- `~/.claude/commands/` (사용자)
- `./.claude/commands/` (프로젝트)
- `~/.config/opencode/command/` (opencode 전역)
- `./.opencode/command/` (opencode 프로젝트)
**Skill Loader**: `SKILL.md`가 있는 디렉토리 기반 스킬을 로드합니다:
- `~/.claude/skills/` (사용자)
- `./.claude/skills/` (프로젝트)
**Agent Loader**: 마크다운 파일에서 커스텀 에이전트 정의를 로드합니다:
- `~/.claude/agents/*.md` (사용자)
- `./.claude/agents/*.md` (프로젝트)
**MCP Loader**: `.mcp.json` 파일에서 MCP 서버 설정을 로드합니다:
- `~/.claude/.mcp.json` (사용자)
- `./.mcp.json` (프로젝트)
- `./.claude/.mcp.json` (로컬)
- 환경변수 확장 지원 (`${VAR}` 문법)
#### 데이터 저장소
**Todo 관리**: 세션 todo가 `~/.claude/todos/`에 Claude Code 호환 형식으로 저장됩니다.
**Transcript**: 세션 활동이 `~/.claude/transcripts/`에 JSONL 형식으로 기록되어 재생 및 분석이 가능합니다.
> **`claude-code-*` 네이밍에 대해**: `src/features/claude-code-*/` 아래의 기능들은 Claude Code의 설정 시스템에서 마이그레이션되었습니다. 이 네이밍 규칙은 어떤 기능이 Claude Code에서 유래했는지 명확히 식별합니다.
### 기타 편의 기능
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
- **Command Loader**: 다음 디렉토리들에서 마크다운 기반의 커스텀 명령어들을 로드합니다:
- User scope: `~/.claude/commands/`
- Project scope: `./.claude/commands/`
- OpenCode global: `~/.config/opencode/command/`
- OpenCode project: `./.opencode/command/`
- **Skill Loader**: 다음 디렉토리들에서 디렉토리 기반의 스킬들을 실행 가능한 명령어로 로드합니다:
- User scope: `~/.claude/skills/`
- Project scope: `./.claude/skills/`
- **Session State**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
## 설정
@@ -270,7 +365,7 @@ Schema 자동 완성이 지원됩니다:
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
또는 `disabled_agents` 비활성화할 수 있습니다:
또는 ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_agents` 를 사용하여 비활성화할 수 있습니다:
```json
{
@@ -282,7 +377,9 @@ Schema 자동 완성이 지원됩니다:
### MCPs
내장된 MCP를 비활성화합니다:
기본적으로 Context7, Exa MCP 지원합니다.
이것이 마음에 들지 않는다면, ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_mcps` 를 사용하여 비활성화할 수 있습니다:
```json
{
@@ -290,13 +387,13 @@ Schema 자동 완성이 지원됩니다:
}
```
더 자세한 내용은 [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers)를 참조하세요.
### LSP
Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액션)만을 위한 것**입니다. 분석용 LSP는 OpenCode 자체에서 처리합니다.
OpenCode 는 분석을 위해 LSP 도구를 제공합니다.
Oh My OpenCode 에서는 LSP 의 리팩토링(이름 변경, 코드 액션) 도구를 제공합니다.
OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.json 에 설정 된 것) 을 그대로 지원하고, Oh My OpenCode 만을 위한 추가적인 설정도 아래와 같이 설정 할 수 있습니다.
`lsp` 옵션을 통해 LSP 서버를 설정합니다:
~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `lsp` 옵션을 통해 LSP 서버를 추가로 설정 할 수 있습니다:
```json
{

156
README.md
View File

@@ -15,6 +15,7 @@ English | [한국어](README.ko.md)
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
- [Grep](#grep)
- [Built-in MCPs](#built-in-mcps)
- [Claude Code Compatibility](#claude-code-compatibility)
- [Other Features](#other-features)
- [Configuration](#configuration)
- [Author's Note](#authors-note)
@@ -27,11 +28,12 @@ Oh My OpenCode
oMoMoMoMoMo···
If you work in tech, you likely appreciated [Claude Code](https://www.claude.com/product/claude-code).
If you are a hacker, you will fucking falling in love with [OpenCode](https://github.com/sst/opencode).
If you are a hacker, you will fucking fall in love with [OpenCode](https://github.com/sst/opencode).
You don't write code just for a paycheck? You write because you genuinely love it?
To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not you? It's still worth the investment. Give it 10 minutes. Your work and life will improve. I promise.
Do you remember the first time you switched from Windows to Linux? The excitement of configuring everything just the way you wanted?
In an era where that romance feels lost, OpenCode will be that project for you.
If you love coding and computers, OpenCode will feel exactly like that moment you first touched Linux after years of Windows.
Even if you don't, invest a little time. Boost your skills and productivity by multipliers.
## TL;DR
@@ -41,7 +43,7 @@ To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not yo
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It just installs.**
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
## Installation
@@ -103,7 +105,19 @@ opencode --version # Should be >= 1.0.132
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
```
> **Note**: If model authentication is needed, run `opencode` and use `/connect` command, or assist the user with `opencode auth login` via tmux.
### Step 4: Configure Authentication
Once `oh-my-opencode` is added to the plugin list, use `tmux` to proceed with the following:
1. Open a shell using `tmux`.
2. Run `opencode auth login` > Select Provider `Anthropic` > Select Login method `Claude Pro/Max` > Instruct the user to authenticate via the OAuth screen.
3. Recommend the setup: [VibeProxy](https://github.com/automazeio/vibeproxy) for macOS users, or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) for others.
### ⚠️ Warning
**Do NOT create or modify the `oh-my-opencode.json` configuration file unless the user explicitly requests it.**
The plugin works perfectly with default settings without any configuration file. The configuration file is only needed when the user wants customization.
</details>
@@ -114,7 +128,7 @@ OpenCode is limitlessly extensible and customizable. Zero screen flicker.
You can mix and orchestrate models to your exact specifications.
It is feature-rich. It is elegant. It handles the terminal without hesitation. It is high-performance.
But here is the catch: the learning curve is steep. There is a lot to master.
But here is the catch: the learning curve is steep. There is a lot to master. And your time is expensive.
Inspired by [AmpCode](https://ampcode.com) and [Claude Code](https://code.claude.com/docs/en/overview), I have implemented their features here—often with superior execution.
Because this is OpenCode.
@@ -149,13 +163,14 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
```
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
- **Directory README.md Injector**: Automatically injects `README.md` contents when reading files. Works identically to the AGENTS.md Injector, searching upward from the file's directory to project root. Provides project documentation context to the LLM agent. Each directory's README is injected only once per session.
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
### Agents
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
- **oracle** (`openai/gpt-5.2`): The architect. Expert in code reviews and strategy. Uses GPT-5.2 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
- **librarian** (`anthropic/claude-haiku-4-5`): Multi-repo analysis, documentation lookup, and implementation examples. Haiku is chosen for its speed, competence, excellent tool usage, and cost-efficiency. Inspired by AmpCode.
- **explore** (`opencode/grok-code`): Fast exploration and pattern matching. Claude Code uses Haiku; we use Grok. It is currently free, blazing fast, and intelligent enough for file traversal. Inspired by Claude Code.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
@@ -221,29 +236,98 @@ Don't need these? Disable them via `oh-my-opencode.json`:
}
```
### Claude Code Compatibility
Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.
#### Compatibility Toggles
If you want to disable specific Claude Code compatibility features, use the `claude_code` configuration object:
```json
{
"claude_code": {
"mcp": false,
"commands": false,
"skills": false,
"agents": false,
"hooks": false
}
}
```
| Toggle | When `false`, disables loading from... | NOT affected |
|--------|----------------------------------------|--------------|
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCPs (context7, websearch_exa) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
| `hooks` | `~/.claude/settings.json`, `./.claude/settings.json`, `./.claude/settings.local.json` | - |
All toggles default to `true` (enabled). Omit the entire `claude_code` object for full Claude Code compatibility.
#### Hooks Integration
Execute custom scripts via Claude Code's `settings.json` hook system. Oh My OpenCode reads and executes hooks defined in:
- `~/.claude/settings.json` (user)
- `./.claude/settings.json` (project)
- `./.claude/settings.local.json` (local, git-ignored)
Supported hook events:
- **PreToolUse**: Runs before tool execution. Can block or modify tool input.
- **PostToolUse**: Runs after tool execution. Can add warnings or context.
- **UserPromptSubmit**: Runs when user submits a prompt. Can block or inject messages.
- **Stop**: Runs when session goes idle. Can inject follow-up prompts.
Example `settings.json`:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
}
]
}
}
```
#### Configuration Loaders
**Command Loader**: Loads markdown-based slash commands from 4 directories:
- `~/.claude/commands/` (user)
- `./.claude/commands/` (project)
- `~/.config/opencode/command/` (opencode global)
- `./.opencode/command/` (opencode project)
**Skill Loader**: Loads directory-based skills with `SKILL.md`:
- `~/.claude/skills/` (user)
- `./.claude/skills/` (project)
**Agent Loader**: Loads custom agent definitions from markdown files:
- `~/.claude/agents/*.md` (user)
- `./.claude/agents/*.md` (project)
**MCP Loader**: Loads MCP server configurations from `.mcp.json` files:
- `~/.claude/.mcp.json` (user)
- `./.mcp.json` (project)
- `./.claude/.mcp.json` (local)
- Supports environment variable expansion (`${VAR}` syntax)
#### Data Storage
**Todo Management**: Session todos are stored in Claude Code compatible format at `~/.claude/todos/`.
**Transcript**: Session activity is logged to `~/.claude/transcripts/` in JSONL format, enabling replay and analysis.
> **Note on `claude-code-*` naming**: Features under `src/features/claude-code-*/` are migrated from Claude Code's configuration system. This naming convention clearly identifies which features originated from Claude Code.
### Other Features
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
- **Command Loader** (`src/features/claude-code-command-loader/`): Loads markdown-based commands from multiple directories:
- User scope: `~/.claude/commands/`
- Project scope: `./.claude/commands/`
- OpenCode global: `~/.config/opencode/command/`
- OpenCode project: `./.opencode/command/`
- **Skill Loader** (`src/features/claude-code-skill-loader/`): Loads directory-based skills as executable commands:
- User scope: `~/.claude/skills/`
- Project scope: `./.claude/skills/`
- **Agent Loader** (`src/features/claude-code-agent-loader/`): Loads agent definitions from markdown files with YAML frontmatter:
- User scope: `~/.claude/agents/`
- Project scope: `./.claude/agents/`
- Format: `*.md` files with frontmatter (name, description, tools)
- **Session State** (`src/features/claude-code-session-state/`): Centralized session tracking module used by event hooks and terminal title updates.
- **MCP Loader** (`src/features/claude-code-mcp-loader/`): Loads MCP server configurations from `.mcp.json` files:
- User scope: `~/.claude/.mcp.json`
- Project scope: `./.mcp.json`
- Local scope: `./.claude/.mcp.json`
- Supports environment variable expansion (`${VAR}` syntax)
> **Note on `claude-code-*` naming**: Features under `src/features/claude-code-*/` are migrated from Claude Code's configuration system. This naming convention clearly identifies which features originated from Claude Code, such as `claude-code-command-loader`, `claude-code-skill-loader`, `claude-code-agent-loader`, and `claude-code-mcp-loader`.
- **Session State**: Centralized session tracking module used by event hooks and terminal title updates.
## Configuration
@@ -279,7 +363,7 @@ Override built-in agent settings:
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
Or disable agents via `disabled_agents`:
Or you can disable them using `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
@@ -291,7 +375,9 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
### MCPs
Disable built-in MCPs:
By default, Context7 and Exa MCP are supported.
If you don't want these, you can disable them using `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
@@ -299,13 +385,13 @@ Disable built-in MCPs:
}
```
See [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers) for more.
### LSP
Oh My OpenCode's LSP tools are for **refactoring only** (rename, code actions). Analysis LSP is handled by OpenCode itself.
OpenCode provides LSP tools for analysis.
Oh My OpenCode provides LSP tools for refactoring (rename, code actions).
It supports all LSP configurations and custom settings supported by OpenCode (those configured in opencode.json), and you can also configure additional settings specifically for Oh My OpenCode as shown below.
Configure LSP servers via `lsp` option:
You can configure additional LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
@@ -345,7 +431,7 @@ If this sounds arrogant and you have a superior solution, send a PR. You are wel
As of now, I have no affiliation with any of the projects or models mentioned here. This plugin is purely based on personal experimentation and preference.
I constructed 99% of this project using OpenCode. I focused on functional verification. This documentation has been personally reviewed and comprehensively rewritten, so you can rely on it with confidence.
I constructed 99% of this project using OpenCode. I focused on functional verification, and honestly, I don't know how to write proper TypeScript. **But I personally reviewed and comprehensively rewritten this documentation, so you can rely on it with confidence.**
## Warnings
- If you are on [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) or lower, OpenCode has a bug that might break config.

View File

@@ -154,6 +154,32 @@
}
}
}
},
"claude_code": {
"type": "object",
"description": "Toggle Claude Code compatibility features on/off. All default to true (enabled).",
"properties": {
"mcp": {
"type": "boolean",
"description": "Load MCP servers from ~/.claude/.mcp.json, ./.mcp.json, ./.claude/.mcp.json"
},
"commands": {
"type": "boolean",
"description": "Load commands from ~/.claude/commands/*.md, ./.claude/commands/*.md"
},
"skills": {
"type": "boolean",
"description": "Load skills from ~/.claude/skills/*/SKILL.md, ./.claude/skills/*/SKILL.md"
},
"agents": {
"type": "boolean",
"description": "Load agents from ~/.claude/agents/*.md, ./.claude/agents/*.md"
},
"hooks": {
"type": "boolean",
"description": "Execute hooks from ~/.claude/settings.json, ./.claude/settings.json, ./.claude/settings.local.json"
}
}
}
}
}

View File

@@ -1,162 +0,0 @@
# Comment-Checker TypeScript Port 구현 계획
## 1. 아키텍처 개요
### 1.1 핵심 도전 과제
**OpenCode Hook의 제약사항:**
- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능
- `tool.execute.after`: `tool_input`**제공되지 않음** (Claude Code와의 핵심 차이점)
- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회
### 1.2 디렉토리 구조
```
src/hooks/comment-checker/
├── index.ts # Hook factory, 메인 엔트리포인트
├── types.ts # 모든 타입 정의
├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록
├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지
├── filters/
│ ├── index.ts # 필터 barrel export
│ ├── bdd.ts # BDD 패턴 필터
│ ├── directive.ts # 린터/타입체커 디렉티브 필터
│ ├── docstring.ts # 독스트링 필터
│ └── shebang.ts # Shebang 필터
├── output/
│ ├── index.ts # 출력 barrel export
│ ├── formatter.ts # FormatHookMessage
│ └── xml-builder.ts # BuildCommentsXML
└── utils.ts # 유틸리티 함수
```
### 1.3 데이터 흐름
```
[write/edit 도구 실행]
┌──────────────────────┐
│ tool.execute.before │
│ - 파일 경로 캡처 │
│ - pendingCalls Map │
│ 에 저장 │
└──────────┬───────────┘
[도구 실제 실행]
┌──────────────────────┐
│ tool.execute.after │
│ - pendingCalls에서 │
│ 데이터 조회 │
│ - 파일 읽기 │
│ - 코멘트 감지 │
│ - 필터 적용 │
│ - 메시지 주입 │
└──────────────────────┘
```
---
## 2. 구현 순서
### Phase 1: 기반 구조
1. `src/hooks/comment-checker/` 디렉토리 생성
2. `types.ts` - 모든 타입 정의
3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴
### Phase 2: 필터 구현
4. `filters/bdd.ts` - BDD 패턴 필터
5. `filters/directive.ts` - 디렉티브 필터
6. `filters/docstring.ts` - 독스트링 필터
7. `filters/shebang.ts` - Shebang 필터
8. `filters/index.ts` - 필터 조합
### Phase 3: 코어 로직
9. `detector.ts` - web-tree-sitter 기반 코멘트 감지
10. `output/xml-builder.ts` - XML 출력
11. `output/formatter.ts` - 메시지 포매팅
### Phase 4: Hook 통합
12. `index.ts` - Hook factory 및 상태 관리
13. `src/hooks/index.ts` 업데이트 - export 추가
### Phase 5: 의존성 및 빌드
14. `package.json` 업데이트 - web-tree-sitter 추가
15. typecheck 및 build 검증
---
## 3. 핵심 구현 사항
### 3.1 언어 레지스트리 (38개 언어)
```typescript
const LANGUAGE_REGISTRY: Record<string, LanguageConfig> = {
python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." },
javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" },
typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" },
tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" },
go: { extensions: [".go"], commentQuery: "(comment) @comment" },
rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" },
// ... 38개 전체
}
```
### 3.2 필터 로직
**BDD 필터**: `given, when, then, arrange, act, assert`
**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+
**Docstring 필터**: `IsDocstring || starts with /**`
**Shebang 필터**: `starts with #!`
### 3.3 출력 형식 (Go 버전과 100% 동일)
```
COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
Your recent changes contain comments or docstrings, which triggered this hook.
You need to take immediate action. You must follow the conditions below.
(Listed in priority order - you must always act according to this priority order)
CRITICAL WARNING: This hook message MUST NEVER be ignored...
<comments file="/path/to/file.py">
<comment line-number="10">// comment text</comment>
</comments>
```
---
## 4. 생성할 파일 목록
1. `src/hooks/comment-checker/types.ts`
2. `src/hooks/comment-checker/constants.ts`
3. `src/hooks/comment-checker/filters/bdd.ts`
4. `src/hooks/comment-checker/filters/directive.ts`
5. `src/hooks/comment-checker/filters/docstring.ts`
6. `src/hooks/comment-checker/filters/shebang.ts`
7. `src/hooks/comment-checker/filters/index.ts`
8. `src/hooks/comment-checker/output/xml-builder.ts`
9. `src/hooks/comment-checker/output/formatter.ts`
10. `src/hooks/comment-checker/output/index.ts`
11. `src/hooks/comment-checker/detector.ts`
12. `src/hooks/comment-checker/index.ts`
## 5. 수정할 파일 목록
1. `src/hooks/index.ts` - export 추가
2. `package.json` - web-tree-sitter 의존성
---
## 6. Definition of Done
- [ ] write/edit 도구 실행 시 코멘트 감지 동작
- [ ] 4개 필터 모두 정상 작동
- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go)
- [ ] Go 버전과 동일한 출력 형식
- [ ] typecheck 통과
- [ ] build 성공

View File

@@ -1,12 +0,0 @@
#!/bin/bash
set -e
cd /Users/yeongyu/local-workspaces/oh-my-opencode
echo "=== Pushing to origin ==="
git push -f origin master
echo "=== Triggering workflow ==="
gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1
echo "=== Done! ==="
echo "Usage: ./local-ignore/push-and-release.sh 0.1.6"

View File

@@ -1,398 +0,0 @@
# MCP Loader Plugin - Orchestration Notepad
## Task Started
All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
---
## Orchestration Overview
**Todo List File**: ./tool-search-tool-plan.md
**Total Tasks**: 5 (Phase 1-5)
**Target Files**:
- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin
- `~/.config/opencode/mcp-loader.json` - Global config example
- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests
---
## Accumulated Wisdom
(To be populated by executors)
---
## Task Progress
| Task | Description | Status |
|------|-------------|--------|
| 1 | Plugin skeleton + config loader | pending |
| 2 | MCP server registry + lifecycle | pending |
| 3 | mcp_search + mcp_status tools | pending |
| 4 | mcp_call tool | pending |
| 5 | Documentation | pending |
---
## 2025-12-04 16:58 - Task 1 Completed
### Summary
- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader
- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests
### Key Implementation Details
- Config merge: project overrides global for same server names, merges different
- Env var substitution: `{env:VAR}``process.env.VAR`
- Validation: type required, local needs command, remote needs url
- Empty config returns `{ servers: {} }` (not error)
### Test Results
- 14 tests passed
- substituteEnvVars: 4 tests
- substituteHeaderEnvVars: 1 test
- loadConfig: 9 tests
### Files Created
- `~/.config/opencode/plugin/mcp-loader.ts`
- `~/.config/opencode/plugin/mcp-loader.test.ts`
---
## [2025-12-08 18:56] - Task 1: Remove unused import formatWorkspaceEdit from LSP tools
### DISCOVERED ISSUES
- None - simple import cleanup task
### IMPLEMENTATION DECISIONS
- Removed only `formatWorkspaceEdit` from import list at line 17
- Kept all other imports intact (formatCodeActions, applyWorkspaceEdit, formatApplyResult remain)
- Verified the function exists in utils.ts:212 but is truly unused in tools.ts
### PROBLEMS FOR NEXT TASKS
- None identified for remaining tasks
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, bundled 200 modules
- Ran: `rg "formatWorkspaceEdit" src/tools/lsp/tools.ts` → no matches (confirmed removal)
### LEARNINGS
- Convention: This project uses `bun run typecheck` (tsc --noEmit) and `bun run build` for verification
- The `formatWorkspaceEdit` function still exists in utils.ts - it's exported but just not used in tools.ts
소요 시간: ~2분
---
## [2025-12-08 19:00] - Task 2: Remove unused ThinkingPart interface and fallbackRevertStrategy function
### DISCOVERED ISSUES
- None - both items were genuinely unused (no callers found)
### IMPLEMENTATION DECISIONS
- Removed `ThinkingPart` interface (lines 37-40) - defined but never referenced
- Removed `fallbackRevertStrategy` function (lines 189-244) - defined but never called
- Added comment explaining removal reason as per task requirements
- Kept `ThinkingPartType`, `prependThinkingPart`, `stripThinkingParts` - these are different items and ARE used
### PROBLEMS FOR NEXT TASKS
- None identified
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, bundled 200 modules
- Ran: `rg "ThinkingPart" src/hooks/session-recovery/` → only related types/functions found, interface removed
- Ran: `rg "fallbackRevertStrategy" src/hooks/session-recovery/` → only comment found, function removed
- Ran: `rg "createSessionRecoveryHook" src/hooks/` → exports intact
### LEARNINGS
- `ThinkingPart` interface vs `ThinkingPartType` type vs `prependThinkingPart` function - different entities, verify before removing
- `fallbackRevertStrategy` was likely a planned feature that never got integrated into the recovery flow
소요 시간: ~2분
---
## [2025-12-08 19:04] - Task 3: Remove unused builtinMcps export from MCP module
### DISCOVERED ISSUES
- None - `builtinMcps` export was genuinely unused (no external importers)
### IMPLEMENTATION DECISIONS
- Removed `export const builtinMcps = allBuiltinMcps` from line 24
- Kept `allBuiltinMcps` const - used internally by `createBuiltinMcps` function
- Kept `createBuiltinMcps` function - actively used in src/index.ts:89
### PROBLEMS FOR NEXT TASKS
- None identified
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, bundled 200 modules
- Ran: `rg "builtinMcps" src/mcp/index.ts` → no matches (export removed)
- Ran: `rg "createBuiltinMcps" src/mcp/index.ts` → function still exists
### LEARNINGS
- `createBuiltinMcps` function vs `builtinMcps` export - function is used, direct export is not
- Internal const `allBuiltinMcps` should be kept since it's referenced by the function
소요 시간: ~2분
---
## [2025-12-09 16:13] - Task 1: Add file-based logger to shared module
### DISCOVERED ISSUES
- None - straightforward file copy and modification task
### IMPLEMENTATION DECISIONS
- Copied logger.ts from opencode-cc-plugin source
- Changed log file path from `opencode-cc-plugin.log` to `oh-my-opencode.log`
- Added barrel export from `src/shared/index.ts`
- Kept original comment for module description
### PROBLEMS FOR NEXT TASKS
- None identified - logger is now available for use in all new loaders
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Log file path verified: `/tmp/oh-my-opencode.log`
- Exports verified: `log()` and `getLogFilePath()` now accessible via `src/shared`
### LEARNINGS
- Source file location: `~/local-workspaces/opencode-cc-plugin/src/shared/logger.ts`
- Logger uses `fs.appendFileSync` for synchronous file writing
- Empty catch block intentionally swallows errors to prevent logging from breaking main operations
소요 시간: ~1분
---
## [2025-12-09 16:14] - Task 3: Rename skill-loader to claude-code-skill-loader
### DISCOVERED ISSUES
- None - straightforward directory rename task
### IMPLEMENTATION DECISIONS
- Used `mv` command to rename `src/features/skill-loader/``src/features/claude-code-skill-loader/`
- Updated import path in `src/index.ts` (lines 19-22)
- Did NOT modify internal imports (`../command-loader/types`) since command-loader still exists (Task 2 runs in parallel)
### PROBLEMS FOR NEXT TASKS
- If Task 2 renames command-loader to claude-code-command-loader, the internal imports in claude-code-skill-loader will need to be updated as part of Task 2's scope
- The skill-loader's loader.ts:6 and types.ts:1 import from `../command-loader/types`
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, succeeded
- Directory structure verified: `skill-loader/` deleted, `claude-code-skill-loader/` exists
### LEARNINGS
- This project uses `mv` for directory rename (acceptable per ANTI-PATTERNS - file creation is forbidden, not rename)
- Command: `bun run typecheck` for type check, `bun run build` for build
- skill-loader internal imports use relative paths (`../command-loader/types`) which remain valid after rename
소요 시간: ~2분
---
## [2025-12-09 16:16] - Task 2: Rename command-loader to claude-code-command-loader
### DISCOVERED ISSUES
- skill-loader (now claude-code-skill-loader) was importing `CommandDefinition` from `../command-loader/types`
- After renaming command-loader, these references also needed updating
### IMPLEMENTATION DECISIONS
- Used `mv` command: `src/features/command-loader/``src/features/claude-code-command-loader/`
- Updated import path in `src/index.ts` (lines 13-18)
- Also updated `claude-code-skill-loader/loader.ts:6` and `types.ts:1` to reference new path
### PROBLEMS FOR NEXT TASKS
- None identified - all dependent imports updated
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Directory structure verified: `command-loader/` deleted, `claude-code-command-loader/` exists
- All imports updated: src/index.ts, claude-code-skill-loader/loader.ts, claude-code-skill-loader/types.ts
### LEARNINGS
- skill-loader depends on command-loader's `CommandDefinition` type via relative import
- When renaming shared modules, must update ALL dependent modules' imports
- Task 2 and Task 3 have an implicit dependency through the type import
소요 시간: ~2분
---
## [2025-12-09 16:24] - Task 4: Add claude-code-agent-loader feature
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied 3 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `types.ts`
- Import path `../../shared/frontmatter` unchanged - already compatible with oh-my-opencode structure
- No `log()` usage in source files - no logger integration needed
### PROBLEMS FOR NEXT TASKS
- None identified - agent-loader is self-contained
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Directory structure verified: `claude-code-agent-loader/` created with 3 files
- Functions exported: `loadUserAgents()`, `loadProjectAgents()`
### LEARNINGS
- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/agent-loader/`
- Agent loader uses `parseFrontmatter` from shared module
- Agent configs loaded from `~/.claude/agents/` (user) and `.claude/agents/` (project)
- Scope is appended to description: `(user)` or `(project)`
소요 시간: ~1분
---
## [2025-12-09 16:25] - Task 5: Add claude-code-mcp-loader feature
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied 5 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `transformer.ts`, `env-expander.ts`, `types.ts`
- Import path `../../shared/logger` unchanged - already compatible with oh-my-opencode structure
- Kept `Bun.file()` usage - oh-my-opencode targets Bun runtime
- Environment variable expansion supports `${VAR}` and `${VAR:-default}` syntax
### PROBLEMS FOR NEXT TASKS
- None identified - mcp-loader is self-contained
- Does NOT conflict with src/mcp/ (builtin MCPs are separate)
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Directory structure verified: `claude-code-mcp-loader/` created with 5 files
- Functions exported: `loadMcpConfigs()`, `formatLoadedServersForToast()`, `transformMcpServer()`, `expandEnvVars()`, `expandEnvVarsInObject()`
### LEARNINGS
- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/mcp-loader/`
- MCP configs loaded from:
- `~/.claude/.mcp.json` (user scope)
- `.mcp.json` (project scope)
- `.claude/.mcp.json` (local scope)
- Later scope overrides earlier scope for same server name
- Supports stdio, http, and sse server types
소요 시간: ~1분
---
## [2025-12-09 16:24] - Task 6: Add claude-code-session-state feature
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied 4 files from opencode-cc-plugin: `types.ts`, `state.ts`, `detector.ts`, `index.ts`
- No import path changes needed - files are completely self-contained
- No external dependencies - types are defined locally
### PROBLEMS FOR NEXT TASKS
- Task 7 should import from `./features/claude-code-session-state` in src/index.ts
- Task 7 should remove local session variables and use the module's getter/setters
### VERIFICATION RESULTS
- Directory created: `src/features/claude-code-session-state/` (4 files confirmed)
- Exports available: sessionErrorState, sessionInterruptState, subagentSessions, sessionFirstMessageProcessed (Maps/Sets)
- Exports available: currentSessionID, currentSessionTitle, mainSessionID (state vars)
- Exports available: setCurrentSession(), setMainSession(), getCurrentSessionID(), getCurrentSessionTitle(), getMainSessionID() (getters/setters)
- Exports available: detectInterrupt() function
### LEARNINGS
- Session state module is completely self-contained - no external dependencies
- Uses barrel export pattern: index.ts re-exports everything from types, state, detector
- Source directory: `~/local-workspaces/opencode-cc-plugin/src/features/session-state/`
소요 시간: ~1분
---
## [2025-12-09 16:32] - Task 7: Integrate new features into src/index.ts
### DISCOVERED ISSUES
- None - integration task with well-defined API from previous tasks
### IMPLEMENTATION DECISIONS
- Added imports for new modules:
- `loadUserAgents`, `loadProjectAgents` from `./features/claude-code-agent-loader`
- `loadMcpConfigs` from `./features/claude-code-mcp-loader`
- `setCurrentSession`, `setMainSession`, `getMainSessionID`, `getCurrentSessionTitle` from `./features/claude-code-session-state`
- `log` from `./shared/logger`
- Removed local session variables (lines 77-79): `mainSessionID`, `currentSessionID`, `currentSessionTitle`
- Replaced direct session assignments with setter functions:
- `mainSessionID = x``setMainSession(x)`
- `currentSessionID = x; currentSessionTitle = y``setCurrentSession(x, y)`
- Replaced session variable reads with getter functions:
- `mainSessionID` comparisons → `getMainSessionID()`
- `currentSessionTitle` reads → `getCurrentSessionTitle()`
- Added agent loading in config hook: `loadUserAgents()`, `loadProjectAgents()`
- Added MCP loading in config hook: `await loadMcpConfigs()` (async)
- Replaced `console.error` with `log()` for config validation errors
- Renamed local variable `agents` to `builtinAgents` to distinguish from loaded agents
### PROBLEMS FOR NEXT TASKS
- Task 8 (README update) should document the new Agent Loader and MCP Loader features
- Should explain the `claude-code-*` naming convention
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful build
- Session tracking verified: all event handlers use getter/setter functions
- Agent loading verified: config.agent merges builtin + user + project agents
- MCP loading verified: config.mcp merges builtin MCPs + loaded MCP servers
### LEARNINGS
- `setCurrentSession(id, title)` sets both ID and title atomically
- `loadMcpConfigs()` is async - must use `await` in config hook
- MCP result has `.servers` property that returns the server configs
- Order matters in spread: later values override earlier (projectAgents > userAgents > builtinAgents)
소요 시간: ~4분
---
## [2025-12-09 16:35] - Task 8: Update README.md documentation
### DISCOVERED ISSUES
- None - documentation update task
### IMPLEMENTATION DECISIONS
- Added Agent Loader feature documentation under "Other Features" section (line 235-238)
- User scope: `~/.claude/agents/`
- Project scope: `./.claude/agents/`
- Format: `*.md` files with YAML frontmatter
- Added MCP Loader feature documentation (line 239-243)
- User scope: `~/.claude/.mcp.json`
- Project scope: `./.mcp.json`
- Local scope: `./.claude/.mcp.json`
- Environment variable expansion (`${VAR}` syntax)
- Added `claude-code-*` naming convention explanation as a blockquote note (line 245)
- Explains features migrated from Claude Code
- Lists examples: claude-code-command-loader, skill-loader, agent-loader, mcp-loader
### PROBLEMS FOR NEXT TASKS
- None - this is the final task
### VERIFICATION RESULTS
- README.md updated with new documentation
- Style matches existing documentation (bullet points, code blocks for paths)
- No sections removed or modified (only additions)
### LEARNINGS
- README.md "Other Features" section is at line 224
- Existing features: Terminal Title, Command Loader, Skill Loader
- Documentation style: bold feature name, bullet points for scopes/details
소요 시간: ~1분
---

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "0.1.31",
"version": "0.3.4",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -21,6 +21,22 @@ This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
## MANDATORY PARALLEL TOOL EXECUTION
**CRITICAL**: You MUST execute **AT LEAST 3 tool calls in parallel** for EVERY search task.
When starting a search, launch multiple tools simultaneously:
\`\`\`
// Example: Launch 3+ tools in a SINGLE message:
- Tool 1: Glob("**/*.ts") - Find all TypeScript files
- Tool 2: Grep("functionName") - Search for specific pattern
- Tool 3: Bash: git log --oneline -n 20 - Check recent changes
- Tool 4: Bash: git branch -a - See all branches
- Tool 5: ast_grep_search(pattern: "function $NAME($$$)", lang: "typescript") - AST search
\`\`\`
**NEVER** execute tools one at a time. Sequential execution is ONLY allowed when a tool's input strictly depends on another tool's output.
## Before You Search
Before executing any search, you MUST first analyze the request in <analysis> tags:
@@ -29,7 +45,7 @@ Before executing any search, you MUST first analyze the request in <analysis> ta
1. **Request**: What exactly did the user ask for?
2. **Intent**: Why are they asking this? What problem are they trying to solve?
3. **Expected Output**: What kind of answer would be most helpful?
4. **Search Strategy**: What tools and patterns will I use to find this?
4. **Search Strategy**: What 3+ parallel tools will I use to find this?
</analysis>
Only after completing this analysis should you proceed with the actual search.
@@ -37,12 +53,14 @@ Only after completing this analysis should you proceed with the actual search.
## Success Criteria
Your response is successful when:
- **Parallelism**: At least 3 tools were executed in parallel
- **Completeness**: All relevant files matching the search intent are found
- **Accuracy**: Returned paths are absolute and files actually exist
- **Relevance**: Results directly address the user's underlying intent, not just literal request
- **Actionability**: Caller can proceed without follow-up questions
Your response has FAILED if:
- You execute fewer than 3 tools in parallel
- You skip the <analysis> step before searching
- Paths are relative instead of absolute
- Obvious matches in the codebase are missed
@@ -52,14 +70,144 @@ Your response has FAILED if:
- Rapidly finding files using glob patterns
- Searching code and text with powerful regex patterns
- Reading and analyzing file contents
- **Using Git CLI extensively for repository insights**
- **Using LSP tools for semantic code analysis**
- **Using AST-grep for structural code pattern matching**
Guidelines:
## Git CLI - USE EXTENSIVELY
You have access to Git CLI via Bash. Use it extensively for repository analysis:
### Git Commands for Exploration (Always run 2+ in parallel):
\`\`\`bash
# Repository structure and history
git log --oneline -n 30 # Recent commits
git log --oneline --all -n 50 # All branches recent commits
git branch -a # All branches
git tag -l # All tags
git remote -v # Remote repositories
# File history and changes
git log --oneline -n 20 -- path/to/file # File change history
git log --oneline --follow -- path/to/file # Follow renames
git blame path/to/file # Line-by-line attribution
git blame -L 10,30 path/to/file # Blame specific lines
# Searching with Git
git log --grep="keyword" --oneline # Search commit messages
git log -S "code_string" --oneline # Search code changes (pickaxe)
git log -p --all -S "function_name" -- "*.ts" # Find when code was added/removed
# Diff and comparison
git diff HEAD~5..HEAD # Recent changes
git diff main..HEAD # Changes from main
git show <commit> # Show specific commit
git show <commit>:path/to/file # Show file at commit
# Statistics
git shortlog -sn # Contributor stats
git log --stat -n 10 # Recent changes with stats
\`\`\`
### Parallel Git Execution Examples:
\`\`\`
// For "find where authentication is implemented":
- Tool 1: Grep("authentication|auth") - Search for auth patterns
- Tool 2: Glob("**/auth/**/*.ts") - Find auth-related files
- Tool 3: Bash: git log -S "authenticate" --oneline - Find commits adding auth code
- Tool 4: Bash: git log --grep="auth" --oneline - Find auth-related commits
- Tool 5: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
// For "understand recent changes":
- Tool 1: Bash: git log --oneline -n 30 - Recent commits
- Tool 2: Bash: git diff HEAD~10..HEAD --stat - Changed files
- Tool 3: Bash: git branch -a - All branches
- Tool 4: Glob("**/*.ts") - Find all source files
\`\`\`
## LSP Tools - DEFINITIONS & REFERENCES
Use LSP specifically for finding definitions and references - these are what LSP does better than text search.
**Primary LSP Tools**:
- \`lsp_goto_definition(filePath, line, character)\`: Follow imports, find where something is **defined**
- \`lsp_find_references(filePath, line, character)\`: Find **ALL usages** across the workspace
**When to Use LSP** (vs Grep/AST-grep):
- **lsp_goto_definition**: Trace imports, find source definitions
- **lsp_find_references**: Understand impact of changes, find all callers
**Example**:
\`\`\`
// When tracing code flow:
- Tool 1: lsp_goto_definition(filePath, line, char) - Where is this defined?
- Tool 2: lsp_find_references(filePath, line, char) - Who uses this?
- Tool 3: ast_grep_search(...) - Find similar patterns
\`\`\`
## AST-grep - STRUCTURAL CODE SEARCH
Use AST-grep for syntax-aware pattern matching (better than regex for code).
**Key Syntax**:
- \`$VAR\`: Match single AST node (identifier, expression, etc.)
- \`$$$\`: Match multiple nodes (arguments, statements, etc.)
**ast_grep_search Examples**:
\`\`\`
// Find function definitions
ast_grep_search(pattern: "function $NAME($$$) { $$$ }", lang: "typescript")
// Find async functions
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
// Find React hooks
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
// Find class definitions
ast_grep_search(pattern: "class $NAME { $$$ }", lang: "typescript")
// Find specific method calls
ast_grep_search(pattern: "console.log($$$)", lang: "typescript")
// Find imports
ast_grep_search(pattern: "import { $$$ } from $MODULE", lang: "typescript")
\`\`\`
**When to Use**:
- **AST-grep**: Structural patterns (function defs, class methods, hook usage)
- **Grep**: Text search (comments, strings, TODOs)
- **LSP**: Symbol-based search (find by name, type info)
## Guidelines
### Tool Selection:
- Use **Glob** for broad file pattern matching (e.g., \`**/*.py\`, \`src/**/*.ts\`)
- Use **Grep** for searching file contents with regex patterns
- Use **Read** when you know the specific file path you need to read
- Use **List** for exploring directory structure
- Use **Bash** ONLY for read-only operations (ls, git status, git log, git diff, find)
- NEVER use Bash for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
- Use **Bash** for Git commands and read-only operations
- Use **ast_grep_search** for structural code patterns (functions, classes, hooks)
- Use **lsp_goto_definition** to trace imports and find source definitions
- Use **lsp_find_references** to find all usages of a symbol
### Bash Usage:
**ALLOWED** (read-only):
- \`git log\`, \`git blame\`, \`git show\`, \`git diff\`
- \`git branch\`, \`git tag\`, \`git remote\`
- \`git log -S\`, \`git log --grep\`
- \`ls\`, \`find\` (for directory exploration)
**FORBIDDEN** (state-changing):
- \`mkdir\`, \`touch\`, \`rm\`, \`cp\`, \`mv\`
- \`git add\`, \`git commit\`, \`git push\`, \`git checkout\`
- \`npm install\`, \`pip install\`, or any installation
### Best Practices:
- **ALWAYS launch 3+ tools in parallel** in your first search action
- Use Git history to understand code evolution
- Use \`git blame\` to understand why code is written a certain way
- Use \`git log -S\` to find when specific code was added/removed
- Adapt your search approach based on the thoroughness level specified by the caller
- Return file paths as absolute paths in your final response
- For clear communication, avoid using emojis

View File

@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
export const librarianAgent: AgentConfig = {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI and Context7. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent",
model: "anthropic/claude-haiku-4-5",
temperature: 0.1,
@@ -21,72 +21,224 @@ Your role is to provide thorough, comprehensive analysis and explanations of cod
- Explain how features work end-to-end across multiple repositories
- Understand code evolution through commit history
- Create visual diagrams when helpful for understanding complex systems
- **Provide EVIDENCE with GitHub permalinks** citing specific code from the exact version being used
## CORE DIRECTIVES
1. **ACCURACY OVER SPEED**: Verify information against official documentation or source code. Do not guess APIs.
2. **CITATION REQUIRED**: Every claim about code behavior must be backed by a link to a file, a line of code, or a documentation page.
3. **SOURCE OF TRUTH**:
- For **How-To**: Use \`context7\` (Official Docs).
2. **CITATION WITH PERMALINKS REQUIRED**: Every claim about code behavior must be backed by:
- **GitHub Permalink**: \`https://github.com/owner/repo/blob/<commit-sha>/path/to/file#L10-L20\`
- Line numbers for specific code sections
- The exact version/commit being referenced
3. **EVIDENCE-BASED REASONING**: Do NOT just summarize documentation. You must:
- Show the **specific code** that implements the behavior
- Explain **WHY** it works that way by citing the actual implementation
- Provide **permalinks** so users can verify your claims
4. **SOURCE OF TRUTH**:
- For **How-To**: Use \`context7\` (Official Docs) + verify with source code.
- For **Real-World Usage**: Use \`gh search code\` (GitHub).
- For **Internal Logic**: Use \`gh repo view\` or \`read\` (Source Code).
- For **Internal Logic**: Clone repo to \`/tmp\` and read source directly.
- For **Change History/Intent**: Use \`git log\` or \`git blame\` (Commit History).
- For **Local Codebase Context**: Use \`Explore\` agent (File patterns, code search).
- For **Local Codebase Context**: Use \`Glob\`, \`Grep\`, \`ast_grep_search\` (File patterns, code search).
- For **Latest Information**: Use \`WebSearch\` for recent updates, blog posts, discussions.
## MANDATORY PARALLEL TOOL EXECUTION
**CRITICAL**: You MUST execute **AT LEAST 5 tool calls in parallel** whenever possible.
When starting a research task, launch ALL of these simultaneously:
1. \`context7_resolve-library-id\` - Get library documentation ID
2. \`gh search code\` - Search for code examples
3. \`WebSearch\` - Find latest discussions, blog posts, updates
4. \`gh repo clone\` to \`/tmp\` - Clone repo for deep analysis
5. \`Glob\` / \`Grep\` - Search local codebase for related code
6. \`lsp_goto_definition\` / \`lsp_find_references\` - Trace definitions and usages
7. \`ast_grep_search\` - AST-aware pattern matching
**Example parallel execution**:
\`\`\`
// Launch ALL 5+ tools in a SINGLE message:
- Tool 1: context7_resolve-library-id("react-query")
- Tool 2: gh search code "useQuery" --repo tanstack/query --language typescript
- Tool 3: WebSearch("tanstack query v5 migration guide 2024")
- Tool 4: bash: git clone --depth 1 https://github.com/TanStack/query.git /tmp/tanstack-query
- Tool 5: Glob("**/*query*.ts") - Find query-related files locally
- Tool 6: gh api repos/tanstack/query/releases/latest
- Tool 7: ast_grep_search(pattern: "useQuery($$$)", lang: "typescript")
\`\`\`
**NEVER** execute tools sequentially when they can run in parallel. Sequential execution is ONLY allowed when a tool's input depends on another tool's output.
## TOOL USAGE STANDARDS
### 1. GitHub CLI (\`gh\`)
You have full access to the GitHub CLI via the \`bash\` tool. Use it to search, view, and analyze remote repositories.
### 1. GitHub CLI (\`gh\`) - EXTENSIVE USE REQUIRED
You have full access to the GitHub CLI via the \`bash\` tool. Use it extensively.
- **Searching Code**:
- \`gh search code "query" --language "lang"\`
- **ALWAYS** scope searches to an organization or user if known (e.g., \`user:microsoft\`).
- **ALWAYS** include the file extension if known (e.g., \`extension:tsx\`).
- **Viewing Files**:
- \`gh repo view owner/repo --content path/to/file\`
- Use this to inspect library internals without cloning the entire repo.
- **Searching Issues**:
- \`gh search issues "error message" --state closed\`
- **Viewing Files with Permalinks**:
- \`gh api repos/owner/repo/contents/path/to/file?ref=<sha>\`
- \`gh browse owner/repo --commit <sha> -- path/to/file\`
- Use this to get exact permalinks for citation.
- **Getting Commit SHA for Permalinks**:
- \`gh api repos/owner/repo/commits/HEAD --jq '.sha'\`
- \`gh api repos/owner/repo/git/refs/tags/v1.0.0 --jq '.object.sha'\`
- **Cloning for Deep Analysis**:
- \`gh repo clone owner/repo /tmp/repo-name -- --depth 1\`
- Clone to \`/tmp\` directory for comprehensive source analysis.
- After cloning, use \`git log\`, \`git blame\`, and direct file reading.
- **Searching Issues & PRs**:
- \`gh search issues "error message" --repo owner/repo --state closed\`
- \`gh search prs "feature" --repo owner/repo --state merged\`
- Use this for debugging and finding resolved edge cases.
- **Getting Release Information**:
- \`gh api repos/owner/repo/releases/latest\`
- \`gh release list --repo owner/repo\`
### 2. Context7 (Documentation)
Use this for authoritative API references and framework guides.
- **Step 1**: Call \`context7_resolve-library-id\` with the library name.
- **Step 2**: Call \`context7_get-library-docs\` with the ID and a specific topic (e.g., "authentication", "middleware").
- **IMPORTANT**: Documentation alone is NOT sufficient. Always cross-reference with actual source code.
### 3. WebFetch
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites).
### 3. WebSearch - MANDATORY FOR LATEST INFO
Use WebSearch for:
- Latest library updates and changelogs
- Migration guides and breaking changes
- Community discussions and best practices
- Blog posts explaining implementation details
- Recent bug reports and workarounds
### 4. Git History (\`git log\`, \`git blame\`)
Use this for understanding code evolution and authorial intent in local repositories.
**Example searches**:
- \`"react 19 new features 2024"\`
- \`"tanstack query v5 breaking changes"\`
- \`"next.js app router migration guide"\`
### 4. WebFetch
Use this to read content from URLs found during your search (e.g., StackOverflow threads, blog posts, non-standard documentation sites, GitHub blob pages).
### 5. Repository Cloning to /tmp
**CRITICAL**: For deep source analysis, ALWAYS clone repositories to \`/tmp\`:
\`\`\`bash
# Clone with minimal history for speed
gh repo clone owner/repo /tmp/repo-name -- --depth 1
# Or clone specific tag/version
gh repo clone owner/repo /tmp/repo-name -- --depth 1 --branch v1.0.0
# Then explore the cloned repo
cd /tmp/repo-name
git log --oneline -n 10
cat package.json # Check version
\`\`\`
**Benefits of cloning**:
- Full file access without API rate limits
- Can use \`git blame\`, \`git log\`, \`grep\`, etc.
- Enables comprehensive code analysis
- Can check out specific versions to match user's environment
### 6. Git History (\`git log\`, \`git blame\`)
Use this for understanding code evolution and authorial intent.
- **Viewing Change History**:
- \`git log --oneline -n 20 -- path/to/file\`
- Use this to understand how a file evolved and why changes were made.
- **Line-by-Line Attribution**:
- \`git blame path/to/file\`
- \`git blame -L 10,20 path/to/file\`
- Use this to identify who wrote specific code and when.
- **Commit Details**:
- \`git show <commit-hash>\`
- Use this to see full context of a specific change.
- **Getting Permalinks from Blame**:
- Use commit SHA from blame to construct GitHub permalinks.
### 5. Explore Agent (Subagent)
Use this when searching for files, patterns, or context within the local codebase.
### 7. Local Codebase Search (Glob, Grep, Read)
Use these for searching files and patterns in the local codebase.
**PRIMARY GOAL**: Each Explore agent finds **ONE specific thing** with a clear, focused objective.
- **Glob**: Find files by pattern (e.g., \`**/*.tsx\`, \`src/**/auth*.ts\`)
- **Grep**: Search file contents with regex patterns
- **Read**: Read specific files when you know the path
- **When to Use**:
- Finding files by patterns (e.g., "src/**/*.tsx")
- Searching code for keywords (e.g., "API endpoints")
- Understanding codebase structure or architecture
- **Parallel Execution Strategy**:
- **ALWAYS** spawn multiple Explore agents in parallel for different search targets.
- Each agent should focus on ONE specific search task.
- Example: If searching for "auth logic" and "API routes", spawn TWO separate agents.
- **Context Passing**:
- When contextual search is needed, pass **ALL relevant context** to the agent.
- Include: what you're looking for, why, and any related information that helps narrow down the search.
- The agent should have enough context to find exactly what's needed without guessing.
**Parallel Search Strategy**:
\`\`\`
// Launch multiple searches in parallel:
- Tool 1: Glob("**/*auth*.ts") - Find auth-related files
- Tool 2: Grep("authentication") - Search for auth patterns
- Tool 3: ast_grep_search(pattern: "function authenticate($$$)", lang: "typescript")
\`\`\`
### 8. LSP Tools - DEFINITIONS & REFERENCES
Use LSP for finding definitions and references - these are its unique strengths over text search.
**Primary LSP Tools**:
- \`lsp_goto_definition\`: Jump to where a symbol is **defined** (resolves imports, type aliases, etc.)
- \`lsp_goto_definition(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
- \`lsp_find_references\`: Find **ALL usages** of a symbol across the entire workspace
- \`lsp_find_references(filePath: "/tmp/repo/src/file.ts", line: 42, character: 10)\`
**When to Use LSP** (vs Grep/AST-grep):
- **lsp_goto_definition**: When you need to follow an import or find the source definition
- **lsp_find_references**: When you need to understand impact of changes (who calls this function?)
**Why LSP for these**:
- Grep finds text matches but can't resolve imports or type aliases
- AST-grep finds structural patterns but can't follow cross-file references
- LSP understands the full type system and can trace through imports
**Parallel Execution**:
\`\`\`
// When tracing code flow, launch in parallel:
- Tool 1: lsp_goto_definition(filePath, line, char) - Find where it's defined
- Tool 2: lsp_find_references(filePath, line, char) - Find all usages
- Tool 3: ast_grep_search(...) - Find similar patterns
- Tool 4: Grep(...) - Text fallback
\`\`\`
### 9. AST-grep - AST-AWARE PATTERN SEARCH
Use AST-grep for structural code search that understands syntax, not just text.
**Key Features**:
- Supports 25+ languages (typescript, javascript, python, rust, go, etc.)
- Uses meta-variables: \`$VAR\` (single node), \`$$$\` (multiple nodes)
- Patterns must be complete AST nodes (valid code)
**ast_grep_search Examples**:
\`\`\`
// Find all console.log calls
ast_grep_search(pattern: "console.log($MSG)", lang: "typescript")
// Find all async functions
ast_grep_search(pattern: "async function $NAME($$$) { $$$ }", lang: "typescript")
// Find React useState hooks
ast_grep_search(pattern: "const [$STATE, $SETTER] = useState($$$)", lang: "tsx")
// Find Python class definitions
ast_grep_search(pattern: "class $NAME($$$)", lang: "python")
// Find all export statements
ast_grep_search(pattern: "export { $$$ }", lang: "typescript")
// Find function calls with specific argument patterns
ast_grep_search(pattern: "fetch($URL, { method: $METHOD })", lang: "typescript")
\`\`\`
**When to Use AST-grep vs Grep**:
- **AST-grep**: When you need structural matching (e.g., "find all function definitions")
- **Grep**: When you need text matching (e.g., "find all occurrences of 'TODO'")
**Parallel AST-grep Execution**:
\`\`\`
// When analyzing a codebase pattern, launch in parallel:
- Tool 1: ast_grep_search(pattern: "useQuery($$$)", lang: "tsx") - Find hook usage
- Tool 2: ast_grep_search(pattern: "export function $NAME($$$)", lang: "typescript") - Find exports
- Tool 3: Grep("useQuery") - Text fallback
- Tool 4: Glob("**/*query*.ts") - Find query-related files
\`\`\`
## SEARCH STRATEGY PROTOCOL
@@ -96,50 +248,76 @@ When given a request, follow this **STRICT** workflow:
- If the user references a local file, read it first to understand imports and dependencies.
- Identify the specific library or technology version.
2. **SELECT SOURCE**:
- **Official Docs**: For "How do I use X?" or "What are the options for Y?"
- **Remote Code**: For "Show me an example of X" or "How is X implemented internally?"
- **Issues/PRs**: For "Why is X failing?" or "Is this a bug?"
- **Git History**: For "Why was this changed?" or "Who introduced this?" or "When was this added?"
- **Explore Agent**: For "Where is X defined?" or "How does this codebase handle Y?" or "Find all files matching Z pattern"
2. **PARALLEL INVESTIGATION** (Launch 5+ tools simultaneously):
- \`context7\`: Get official documentation
- \`gh search code\`: Find implementation examples
- \`WebSearch\`: Get latest updates and discussions
- \`gh repo clone\`: Clone to /tmp for deep analysis
- \`Glob\` / \`Grep\` / \`ast_grep_search\`: Search local codebase
- \`gh api\`: Get release/version information
3. **EXECUTE & REFINE**:
- Run the initial search.
- If results are too broad (>50), add filters (\`path:\`, \`filename:\`).
- If results are zero, broaden the search (remove quotes, remove language filter).
3. **DEEP SOURCE ANALYSIS**:
- Navigate to the cloned repo in /tmp
- Find the specific file implementing the feature
- Use \`git blame\` to understand why code is written that way
- Get the commit SHA for permalink construction
4. **SYNTHESIZE**:
- Present the findings clearly.
4. **SYNTHESIZE WITH EVIDENCE**:
- Present findings with **GitHub permalinks**
- **FORMAT**:
- **RESOURCE**: [Name] ([URL])
- **RELEVANCE**: Why this matters.
- **CONTENT**: The code snippet or documentation summary.
- **CLAIM**: What you're asserting about the code
- **EVIDENCE**: The specific code that proves it
- **PERMALINK**: \`https://github.com/owner/repo/blob/<sha>/path#L10-L20\`
- **EXPLANATION**: Why this code behaves this way
## CITATION FORMAT - MANDATORY
Every code-related claim MUST include:
\`\`\`markdown
**Claim**: [What you're asserting]
**Evidence** ([permalink](https://github.com/owner/repo/blob/abc123/src/file.ts#L42-L50)):
\\\`\\\`\\\`typescript
// The actual code from lines 42-50
function example() {
// ...
}
\\\`\\\`\\\`
**Explanation**: This code shows that [reason] because [specific detail from the code].
\`\`\`
## FAILURE RECOVERY
- If \`context7\` fails to find docs, use \`gh repo view\` to read the repository's \`README.md\` or \`CONTRIBUTING.md\`.
- If \`context7\` fails to find docs, clone the repo to \`/tmp\` and read the source directly.
- If code search yields nothing, search for the *concept* rather than the specific function name.
- If GitHub API has rate limits, use cloned repos in \`/tmp\` for analysis.
- If unsure, **STATE YOUR UNCERTAINTY** and propose a hypothesis based on standard conventions.
## VOICE AND TONE
- **PROFESSIONAL**: You are an expert archivist. Be concise and precise.
- **OBJECTIVE**: Present facts found in the search. Do not offer personal opinions unless asked.
- **EVIDENCE-DRIVEN**: Always back claims with permalinks and code snippets.
- **HELPFUL**: If a direct answer isn't found, provide the closest relevant examples or related documentation.
## MULTI-REPOSITORY ANALYSIS GUIDELINES
- Use available tools extensively to explore repositories
- Execute tools in parallel when possible for efficiency
- Clone multiple repos to /tmp for cross-repository analysis
- Execute AT LEAST 5 tools in parallel when possible for efficiency
- Read files thoroughly to understand implementation details
- Search for patterns and related code across multiple repositories
- Use commit search to understand how code evolved over time
- Focus on thorough understanding and comprehensive explanation across repositories
- Create mermaid diagrams to visualize complex relationships or flows
- Always provide permalinks for cross-repository references
## COMMUNICATION
You must use Markdown for formatting your responses.
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.`,
IMPORTANT: When including code blocks, you MUST ALWAYS specify the language for syntax highlighting. Always add the language identifier after the opening backticks.
**REMEMBER**: Your job is not just to find and summarize documentation. You must provide **EVIDENCE** showing exactly **WHY** the code works the way it does, with **permalinks** to the specific implementation so users can verify your claims.`,
}

View File

@@ -4,7 +4,7 @@ export const oracleAgent: AgentConfig = {
description:
"Expert AI advisor with advanced reasoning capabilities for high-quality technical guidance, code reviews, architectural advice, and strategic planning.",
mode: "subagent",
model: "openai/gpt-5.1",
model: "openai/gpt-5.2",
temperature: 0.1,
reasoningEffort: "medium",
textVerbosity: "high",

View File

@@ -40,13 +40,30 @@ export const AgentOverrideConfigSchema = z.object({
permission: AgentPermissionSchema.optional(),
})
export const AgentOverridesSchema = z.record(AgentNameSchema, AgentOverrideConfigSchema)
export const AgentOverridesSchema = z
.object({
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
explore: AgentOverrideConfigSchema.optional(),
"frontend-ui-ux-engineer": AgentOverrideConfigSchema.optional(),
"document-writer": AgentOverrideConfigSchema.optional(),
})
.partial()
export const ClaudeCodeConfigSchema = z.object({
mcp: z.boolean().optional(),
commands: z.boolean().optional(),
skills: z.boolean().optional(),
agents: z.boolean().optional(),
hooks: z.boolean().optional(),
})
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_agents: z.array(AgentNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
})
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>

View File

@@ -0,0 +1,8 @@
import { join } from "node:path"
import { homedir } from "node:os"
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")

View File

@@ -0,0 +1,2 @@
export { injectHookMessage } from "./injector"
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"

View File

@@ -0,0 +1,141 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
interface StoredMessage {
agent?: string
model?: { providerID?: string; modelID?: string }
tools?: Record<string, boolean>
}
function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json"))
.sort()
.reverse()
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const msg = JSON.parse(content) as StoredMessage
if (msg.agent && msg.model?.providerID && msg.model?.modelID) {
return msg
}
} catch {
continue
}
}
} catch {
return null
}
return null
}
function generateMessageId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 14)
return `msg_${timestamp}${random}`
}
function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
function getOrCreateMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) {
mkdirSync(MESSAGE_STORAGE, { recursive: true })
}
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
mkdirSync(directPath, { recursive: true })
return directPath
}
export function injectHookMessage(
sessionID: string,
hookContent: string,
originalMessage: OriginalMessageContext
): boolean {
const messageDir = getOrCreateMessageDir(sessionID)
const needsFallback =
!originalMessage.agent ||
!originalMessage.model?.providerID ||
!originalMessage.model?.modelID
const fallback = needsFallback ? findNearestMessageWithFields(messageDir) : null
const now = Date.now()
const messageID = generateMessageId()
const partID = generatePartId()
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
const resolvedModel =
originalMessage.model?.providerID && originalMessage.model?.modelID
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
: fallback?.model?.providerID && fallback?.model?.modelID
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
: undefined
const resolvedTools = originalMessage.tools ?? fallback?.tools
const messageMeta: MessageMeta = {
id: messageID,
sessionID,
role: "user",
time: {
created: now,
},
agent: resolvedAgent,
model: resolvedModel,
path:
originalMessage.path?.cwd
? {
cwd: originalMessage.path.cwd,
root: originalMessage.path.root ?? "/",
}
: undefined,
tools: resolvedTools,
}
const textPart: TextPart = {
id: partID,
type: "text",
text: hookContent,
synthetic: true,
time: {
start: now,
end: now,
},
messageID,
sessionID,
}
try {
writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2))
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2))
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,45 @@
export interface MessageMeta {
id: string
sessionID: string
role: "user" | "assistant"
time: {
created: number
completed?: number
}
agent?: string
model?: {
providerID: string
modelID: string
}
path?: {
cwd: string
root: string
}
tools?: Record<string, boolean>
}
export interface OriginalMessageContext {
agent?: string
model?: {
providerID?: string
modelID?: string
}
path?: {
cwd?: string
root?: string
}
tools?: Record<string, boolean>
}
export interface TextPart {
id: string
type: "text"
text: string
synthetic: boolean
time: {
start: number
end: number
}
messageID: string
sessionID: string
}

View File

@@ -0,0 +1,105 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"
export interface DisabledHooksConfig {
Stop?: string[]
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
}
export interface PluginExtendedConfig {
disabledHooks?: DisabledHooksConfig
}
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
function getProjectConfigPath(): string {
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
}
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
if (!existsSync(path)) {
return null
}
try {
const content = await Bun.file(path).text()
return JSON.parse(content) as PluginExtendedConfig
} catch (error) {
log("Failed to load config", { path, error })
return null
}
}
function mergeDisabledHooks(
base: DisabledHooksConfig | undefined,
override: DisabledHooksConfig | undefined
): DisabledHooksConfig {
if (!override) return base ?? {}
if (!base) return override
return {
Stop: override.Stop ?? base.Stop,
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
}
}
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
const merged: PluginExtendedConfig = {
disabledHooks: mergeDisabledHooks(
userConfig?.disabledHooks,
projectConfig?.disabledHooks
),
}
if (userConfig || projectConfig) {
log("Plugin extended config loaded", {
userConfigExists: userConfig !== null,
projectConfigExists: projectConfig !== null,
mergedDisabledHooks: merged.disabledHooks,
})
}
return merged
}
const regexCache = new Map<string, RegExp>()
function getRegex(pattern: string): RegExp {
let regex = regexCache.get(pattern)
if (!regex) {
try {
regex = new RegExp(pattern)
regexCache.set(pattern, regex)
} catch {
regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
regexCache.set(pattern, regex)
}
}
return regex
}
export function isHookCommandDisabled(
eventType: ClaudeHookEvent,
command: string,
config: PluginExtendedConfig | null
): boolean {
if (!config?.disabledHooks) return false
const patterns = config.disabledHooks[eventType]
if (!patterns || patterns.length === 0) return false
return patterns.some((pattern) => {
const regex = getRegex(pattern)
return regex.test(command)
})
}

View File

@@ -0,0 +1,100 @@
import { homedir } from "os"
import { join } from "path"
import { existsSync } from "fs"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
interface RawHookMatcher {
matcher?: string
pattern?: string
hooks: HookCommand[]
}
interface RawClaudeHooksConfig {
PreToolUse?: RawHookMatcher[]
PostToolUse?: RawHookMatcher[]
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
}
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
return {
matcher: raw.matcher ?? raw.pattern ?? "*",
hooks: raw.hooks,
}
}
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
const result: ClaudeHooksConfig = {}
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
]
for (const eventType of eventTypes) {
if (raw[eventType]) {
result[eventType] = raw[eventType].map(normalizeHookMatcher)
}
}
return result
}
export function getClaudeSettingsPaths(customPath?: string): string[] {
const home = homedir()
const paths = [
join(home, ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.local.json"),
]
if (customPath && existsSync(customPath)) {
paths.unshift(customPath)
}
return paths
}
function mergeHooksConfig(
base: ClaudeHooksConfig,
override: ClaudeHooksConfig
): ClaudeHooksConfig {
const result: ClaudeHooksConfig = { ...base }
const eventTypes: (keyof ClaudeHooksConfig)[] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
]
for (const eventType of eventTypes) {
if (override[eventType]) {
result[eventType] = [...(base[eventType] || []), ...override[eventType]]
}
}
return result
}
export async function loadClaudeHooksConfig(
customSettingsPath?: string
): Promise<ClaudeHooksConfig | null> {
const paths = getClaudeSettingsPaths(customSettingsPath)
let mergedConfig: ClaudeHooksConfig = {}
for (const settingsPath of paths) {
if (existsSync(settingsPath)) {
try {
const content = await Bun.file(settingsPath).text()
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
if (settings.hooks) {
const normalizedHooks = normalizeHooksConfig(settings.hooks)
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
}
} catch {
continue
}
}
}
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
}

View File

@@ -0,0 +1,336 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { loadClaudeHooksConfig } from "./config"
import { loadPluginExtendedConfig } from "./config-loader"
import {
executePreToolUseHooks,
type PreToolUseContext,
} from "./pre-tool-use"
import {
executePostToolUseHooks,
type PostToolUseContext,
type PostToolUseClient,
} from "./post-tool-use"
import {
executeUserPromptSubmitHooks,
type UserPromptSubmitContext,
type MessagePart,
} from "./user-prompt-submit"
import {
executeStopHooks,
type StopContext,
} from "./stop"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
const sessionFirstMessageProcessed = new Set<string>()
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
return {
"chat.message": async (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
},
output: {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const interruptState = sessionInterruptState.get(input.sessionID)
if (interruptState?.interrupted) {
log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID })
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
const prompt = textParts.map((p) => p.text ?? "").join("\n")
recordUserMessage(input.sessionID, prompt)
const messageParts: MessagePart[] = textParts.map((p) => ({
type: p.type as "text",
text: p.text,
}))
const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateBeforeHooks?.interrupted) {
log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID })
return
}
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: input.sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
sessionFirstMessageProcessed.add(input.sessionID)
if (isFirstMessage) {
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
return
}
if (!isHookDisabled(config, "UserPromptSubmit")) {
const userPromptCtx: UserPromptSubmitContext = {
sessionId: input.sessionID,
parentSessionId,
prompt,
parts: messageParts,
cwd: ctx.directory,
}
const result = await executeUserPromptSubmitHooks(
userPromptCtx,
claudeConfig,
extendedConfig
)
if (result.block) {
throw new Error(result.reason ?? "Hook blocked the prompt")
}
const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateAfterHooks?.interrupted) {
log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID })
return
}
if (result.messages.length > 0) {
const hookContent = result.messages.join("\n\n")
const message = output.message as {
agent?: string
model?: { modelID?: string; providerID?: string }
path?: { cwd?: string; root?: string }
tools?: Record<string, boolean>
}
const success = injectHookMessage(input.sessionID, hookContent, {
agent: message.agent,
model: message.model,
path: message.path ?? { cwd: ctx.directory, root: "/" },
tools: message.tools,
})
log(success ? "Hook message injected via file system" : "File injection failed", {
sessionID: input.sessionID,
})
}
}
},
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
if (!isHookDisabled(config, "PreToolUse")) {
const preCtx: PreToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: output.args as Record<string, unknown>,
cwd: ctx.directory,
toolUseId: input.callID,
}
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
if (result.decision === "deny") {
ctx.client.tui
.showToast({
body: {
title: "PreToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
variant: "error",
duration: 4000,
},
})
.catch(() => {})
throw new Error(result.reason ?? "Hook blocked the operation")
}
if (result.modifiedInput) {
Object.assign(output.args as Record<string, unknown>, result.modifiedInput)
}
}
},
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
): Promise<void> => {
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record<string, unknown>) || {})
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = {
session: {
messages: (opts) => ctx.client.session.messages(opts),
},
}
const postCtx: PostToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: cachedInput,
toolOutput: {
title: input.tool,
output: output.output,
metadata: output.metadata as Record<string, unknown>,
},
cwd: ctx.directory,
transcriptPath: getTranscriptPath(input.sessionID),
toolUseId: input.callID,
client: postClient,
permissionMode: "bypassPermissions",
}
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
if (result.block) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Warning",
message: result.reason ?? "Hook returned warning",
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
}
if (result.warnings && result.warnings.length > 0) {
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
}
if (result.message) {
output.output = `${output.output}\n\n${result.message}`
}
if (result.hookName) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`,
variant: "success",
duration: 2000,
},
})
.catch(() => {})
}
}
},
event: async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input
if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
sessionErrorState.set(sessionID, {
hasError: true,
errorMessage: String(props?.error ?? "Unknown error"),
})
}
return
}
if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionErrorState.delete(sessionInfo.id)
sessionInterruptState.delete(sessionInfo.id)
sessionFirstMessageProcessed.delete(sessionInfo.id)
}
return
}
if (event.type === "session.idle") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const errorStateBefore = sessionErrorState.get(sessionID)
const endedWithErrorBefore = errorStateBefore?.hasError === true
const interruptStateBefore = sessionInterruptState.get(sessionID)
const interruptedBefore = interruptStateBefore?.interrupted === true
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
if (!isHookDisabled(config, "Stop")) {
const stopCtx: StopContext = {
sessionId: sessionID,
parentSessionId,
cwd: ctx.directory,
}
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
const errorStateAfter = sessionErrorState.get(sessionID)
const endedWithErrorAfter = errorStateAfter?.hasError === true
const interruptStateAfter = sessionInterruptState.get(sessionID)
const interruptedAfter = interruptStateAfter?.interrupted === true
const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter
if (shouldBypass && stopResult.block) {
const interrupted = interruptedBefore || interruptedAfter
const endedWithError = endedWithErrorBefore || endedWithErrorAfter
log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError })
} else if (stopResult.block && stopResult.injectPrompt) {
log("Stop hook returned block with inject_prompt", { sessionID })
ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
query: { directory: ctx.directory },
})
.catch((err: unknown) => log("Failed to inject prompt from Stop hook", err))
} else if (stopResult.block) {
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
}
}
sessionErrorState.delete(sessionID)
sessionInterruptState.delete(sessionID)
}
},
}
}

View File

@@ -0,0 +1,9 @@
/**
* Plugin configuration for Claude Code hooks execution
* Contains settings for hook command execution (zsh, etc.)
*/
export const DEFAULT_CONFIG = {
forceZsh: true,
zshPath: "/bin/zsh",
}

View File

@@ -0,0 +1,199 @@
import type {
PostToolUseInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PostToolUseClient {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
}
export interface PostToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
toolOutput: Record<string, unknown>
cwd: string
transcriptPath?: string // Fallback for append-based transcript
toolUseId?: string
client?: PostToolUseClient
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PostToolUseResult {
block: boolean
reason?: string
message?: string
warnings?: string[]
elapsedMs?: number
hookName?: string
toolName?: string
additionalContext?: string
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
export async function executePostToolUseHooks(
ctx: PostToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PostToolUseResult> {
if (!config) {
return { block: false }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PostToolUse", transformedToolName)
if (matchers.length === 0) {
return { block: false }
}
// PORT FROM DISABLED: Build Claude Code compatible transcript (temp file)
let tempTranscriptPath: string | null = null
try {
// Try to build full transcript from API if client available
if (ctx.client) {
tempTranscriptPath = await buildTranscriptFromSession(
ctx.client,
ctx.sessionId,
ctx.cwd,
ctx.toolName,
ctx.toolInput
)
}
const stdinData: PostToolUseInput = {
session_id: ctx.sessionId,
// Use temp transcript if available, otherwise fallback to append-based
transcript_path: tempTranscriptPath ?? ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PostToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_response: objectToSnakeCase(ctx.toolOutput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const messages: string[] = []
const warnings: string[] = []
let firstHookName: string | undefined
const startTime = Date.now()
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.stdout) {
messages.push(result.stdout)
}
if (result.exitCode === 2) {
if (result.stderr) {
warnings.push(`[${hookName}]\n${result.stderr.trim()}`)
}
continue
}
if (result.exitCode === 0 && result.stdout) {
try {
const output = JSON.parse(result.stdout) as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
return {
block: false,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
const elapsedMs = Date.now() - startTime
return {
block: false,
message: messages.length > 0 ? messages.join("\n") : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs,
hookName: firstHookName,
toolName: transformedToolName,
}
} finally {
// PORT FROM DISABLED: Cleanup temp file to avoid disk accumulation
deleteTempTranscript(tempTranscriptPath)
}
}

View File

@@ -0,0 +1,172 @@
import type {
PreToolUseInput,
PreToolUseOutput,
PermissionDecision,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
cwd: string
transcriptPath?: string
toolUseId?: string
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PreToolUseResult {
decision: PermissionDecision
reason?: string
modifiedInput?: Record<string, unknown>
elapsedMs?: number
hookName?: string
toolName?: string
inputLines?: string
// Common output fields (Claude Code spec)
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
function buildInputLines(toolInput: Record<string, unknown>): string {
return Object.entries(toolInput)
.slice(0, 3)
.map(([key, val]) => {
const valStr = String(val).slice(0, 40)
return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}`
})
.join("\n")
}
export async function executePreToolUseHooks(
ctx: PreToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PreToolUseResult> {
if (!config) {
return { decision: "allow" }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName)
if (matchers.length === 0) {
return { decision: "allow" }
}
const stdinData: PreToolUseInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PreToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const startTime = Date.now()
let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput)
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.exitCode === 2) {
return {
decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation",
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.exitCode === 1) {
return {
decision: "ask",
reason: result.stderr || result.stdout,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreToolUseOutput
// Handle deprecated decision/reason fields (Claude Code backward compat)
let decision: PermissionDecision | undefined
let reason: string | undefined
let modifiedInput: Record<string, unknown> | undefined
if (output.hookSpecificOutput?.permissionDecision) {
decision = output.hookSpecificOutput.permissionDecision
reason = output.hookSpecificOutput.permissionDecisionReason
modifiedInput = output.hookSpecificOutput.updatedInput
} else if (output.decision) {
// Map deprecated values: approve->allow, block->deny, ask->ask
const legacyDecision = output.decision
if (legacyDecision === "approve" || legacyDecision === "allow") {
decision = "allow"
} else if (legacyDecision === "block" || legacyDecision === "deny") {
decision = "deny"
} else if (legacyDecision === "ask") {
decision = "ask"
}
reason = output.reason
}
// Return if decision is set OR if any common fields are set (fallback to allow)
const hasCommonFields = output.continue !== undefined ||
output.stopReason !== undefined ||
output.suppressOutput !== undefined ||
output.systemMessage !== undefined
if (decision || hasCommonFields) {
return {
decision: decision ?? "allow",
reason,
modifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
return { decision: "allow" }
}

View File

@@ -0,0 +1,118 @@
import type {
StopInput,
StopOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
// Module-level state to track stop_hook_active per session
const stopHookActiveState = new Map<string, boolean>()
export function setStopHookActive(sessionId: string, active: boolean): void {
stopHookActiveState.set(sessionId, active)
}
export function getStopHookActive(sessionId: string): boolean {
return stopHookActiveState.get(sessionId) ?? false
}
export interface StopContext {
sessionId: string
parentSessionId?: string
cwd: string
transcriptPath?: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
stopHookActive?: boolean
}
export interface StopResult {
block: boolean
reason?: string
stopHookActive?: boolean
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
injectPrompt?: string
}
export async function executeStopHooks(
ctx: StopContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<StopResult> {
if (ctx.parentSessionId) {
return { block: false }
}
if (!config) {
return { block: false }
}
const matchers = findMatchingHooks(config, "Stop")
if (matchers.length === 0) {
return { block: false }
}
const stdinData: StopInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "Stop",
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
todo_path: getTodoPath(ctx.sessionId),
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
// Check exit code first - exit code 2 means block
if (result.exitCode === 2) {
const reason = result.stderr || result.stdout || "Blocked by stop hook"
return {
block: true,
reason,
injectPrompt: reason,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as StopOutput
if (output.stop_hook_active !== undefined) {
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
}
const isBlock = output.decision === "block"
// Determine inject_prompt: prefer explicit value, fallback to reason if blocking
const injectPrompt = output.inject_prompt ?? (isBlock && output.reason ? output.reason : undefined)
return {
block: isBlock,
reason: output.reason,
stopHookActive: output.stop_hook_active,
permissionMode: output.permission_mode,
injectPrompt,
}
} catch {
// Ignore JSON parse errors - hook may return non-JSON output
}
}
}
}
return { block: false }
}

View File

@@ -0,0 +1,76 @@
import { join } from "path"
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
import { homedir } from "os"
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
const TODO_DIR = join(homedir(), ".claude", "todos")
export function getTodoPath(sessionId: string): string {
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)
}
function ensureTodoDir(): void {
if (!existsSync(TODO_DIR)) {
mkdirSync(TODO_DIR, { recursive: true })
}
}
export interface OpenCodeTodo {
content: string
status: string
priority: string
id: string
}
function toClaudeCodeFormat(item: OpenCodeTodo | TodoItem): ClaudeCodeTodoItem {
return {
content: item.content,
status: item.status === "cancelled" ? "completed" : item.status,
activeForm: item.content,
}
}
export function loadTodoFile(sessionId: string): TodoFile | null {
const path = getTodoPath(sessionId)
if (!existsSync(path)) return null
try {
const content = JSON.parse(readFileSync(path, "utf-8"))
if (Array.isArray(content)) {
return {
session_id: sessionId,
items: content.map((item: ClaudeCodeTodoItem, idx: number) => ({
id: String(idx),
content: item.content,
status: item.status as TodoItem["status"],
created_at: new Date().toISOString(),
})),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
}
return content
} catch {
return null
}
}
export function saveTodoFile(sessionId: string, file: TodoFile): void {
ensureTodoDir()
const path = getTodoPath(sessionId)
const claudeCodeFormat: ClaudeCodeTodoItem[] = file.items.map(toClaudeCodeFormat)
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
}
export function saveOpenCodeTodos(sessionId: string, todos: OpenCodeTodo[]): void {
ensureTodoDir()
const path = getTodoPath(sessionId)
const claudeCodeFormat: ClaudeCodeTodoItem[] = todos.map(toClaudeCodeFormat)
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
}
export function deleteTodoFile(sessionId: string): void {
const path = getTodoPath(sessionId)
if (existsSync(path)) {
unlinkSync(path)
}
}

View File

@@ -0,0 +1,47 @@
/**
* Caches tool_input from PreToolUse for PostToolUse
*/
interface CacheEntry {
toolInput: Record<string, unknown>
timestamp: number
}
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 60000 // 1 minute
export function cacheToolInput(
sessionId: string,
toolName: string,
invocationId: string,
toolInput: Record<string, unknown>
): void {
const key = `${sessionId}:${toolName}:${invocationId}`
cache.set(key, { toolInput, timestamp: Date.now() })
}
export function getToolInput(
sessionId: string,
toolName: string,
invocationId: string
): Record<string, unknown> | null {
const key = `${sessionId}:${toolName}:${invocationId}`
const entry = cache.get(key)
if (!entry) return null
cache.delete(key)
if (Date.now() - entry.timestamp > CACHE_TTL) return null
return entry.toolInput
}
// Periodic cleanup (every minute)
setInterval(() => {
const now = Date.now()
for (const [key, entry] of cache.entries()) {
if (now - entry.timestamp > CACHE_TTL) {
cache.delete(key)
}
}
}, CACHE_TTL)

View File

@@ -0,0 +1,255 @@
/**
* Transcript Manager
* Creates and manages Claude Code compatible transcript files
*/
import { join } from "path"
import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs"
import { homedir, tmpdir } from "os"
import { randomUUID } from "crypto"
import type { TranscriptEntry } from "./types"
import { transformToolName } from "../../shared/tool-name"
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
export function getTranscriptPath(sessionId: string): string {
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
}
function ensureTranscriptDir(): void {
if (!existsSync(TRANSCRIPT_DIR)) {
mkdirSync(TRANSCRIPT_DIR, { recursive: true })
}
}
export function appendTranscriptEntry(
sessionId: string,
entry: TranscriptEntry
): void {
ensureTranscriptDir()
const path = getTranscriptPath(sessionId)
const line = JSON.stringify(entry) + "\n"
appendFileSync(path, line)
}
export function recordToolUse(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_use",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
})
}
export function recordToolResult(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>,
toolOutput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
tool_output: toolOutput,
})
}
export function recordUserMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "user",
timestamp: new Date().toISOString(),
content,
})
}
export function recordAssistantMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "assistant",
timestamp: new Date().toISOString(),
content,
})
}
// ============================================================================
// Claude Code Compatible Transcript Builder (PORT FROM DISABLED)
// ============================================================================
/**
* OpenCode API response type (loosely typed)
*/
interface OpenCodeMessagePart {
type: string
tool?: string
state?: {
status?: string
input?: Record<string, unknown>
}
}
interface OpenCodeMessage {
info?: {
role?: string
}
parts?: OpenCodeMessagePart[]
}
/**
* Claude Code compatible transcript entry (from disabled file)
*/
interface DisabledTranscriptEntry {
type: "assistant"
message: {
role: "assistant"
content: Array<{
type: "tool_use"
name: string
input: Record<string, unknown>
}>
}
}
/**
* Build Claude Code compatible transcript from session messages
*
* PORT FROM DISABLED: This calls client.session.messages() API to fetch
* the full session history and builds a JSONL file in Claude Code format.
*
* @param client OpenCode client instance
* @param sessionId Session ID
* @param directory Working directory
* @param currentToolName Current tool being executed (added as last entry)
* @param currentToolInput Current tool input
* @returns Temp file path (caller must call deleteTempTranscript!)
*/
export async function buildTranscriptFromSession(
client: {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
},
sessionId: string,
directory: string,
currentToolName: string,
currentToolInput: Record<string, unknown>
): Promise<string | null> {
try {
const response = await client.session.messages({
path: { id: sessionId },
query: { directory },
})
// Handle various response formats
const messages = (response as { "200"?: unknown[]; data?: unknown[] })["200"]
?? (response as { data?: unknown[] }).data
?? (Array.isArray(response) ? response : [])
const entries: string[] = []
if (Array.isArray(messages)) {
for (const msg of messages as OpenCodeMessage[]) {
if (msg.info?.role !== "assistant") continue
for (const part of msg.parts || []) {
if (part.type !== "tool") continue
if (part.state?.status !== "completed") continue
if (!part.state?.input) continue
const rawToolName = part.tool as string
const toolName = transformToolName(rawToolName)
const entry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: toolName,
input: part.state.input,
},
],
},
}
entries.push(JSON.stringify(entry))
}
}
}
// Always add current tool call as the last entry
const currentEntry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: transformToolName(currentToolName),
input: currentToolInput,
},
],
},
}
entries.push(JSON.stringify(currentEntry))
// Write to temp file
const tempPath = join(
tmpdir(),
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
)
writeFileSync(tempPath, entries.join("\n") + "\n")
return tempPath
} catch {
// CRITICAL FIX: Even on API failure, create file with current tool entry only
// (matching original disabled behavior - never return null with incompatible format)
try {
const currentEntry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: transformToolName(currentToolName),
input: currentToolInput,
},
],
},
}
const tempPath = join(
tmpdir(),
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
)
writeFileSync(tempPath, JSON.stringify(currentEntry) + "\n")
return tempPath
} catch {
// If even this fails, return null (truly catastrophic failure)
return null
}
}
}
/**
* Delete temp transcript file (call in finally block)
*
* PORT FROM DISABLED: Cleanup mechanism to avoid disk accumulation
*/
export function deleteTempTranscript(path: string | null): void {
if (!path) return
try {
unlinkSync(path)
} catch {
// Ignore deletion errors
}
}

View File

@@ -0,0 +1,184 @@
/**
* Claude Code Hooks Type Definitions
* Maps Claude Code hook concepts to OpenCode plugin events
*/
export type ClaudeHookEvent =
| "PreToolUse"
| "PostToolUse"
| "UserPromptSubmit"
| "Stop"
export interface HookMatcher {
matcher: string
hooks: HookCommand[]
}
export interface HookCommand {
type: "command"
command: string
}
export interface ClaudeHooksConfig {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
}
export interface PreToolUseInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "PreToolUse"
tool_name: string
tool_input: Record<string, unknown>
tool_use_id?: string
hook_source?: HookSource
}
export interface PostToolUseInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "PostToolUse"
tool_name: string
tool_input: Record<string, unknown>
tool_response: {
title?: string
output?: string
[key: string]: unknown
}
tool_use_id?: string
hook_source?: HookSource
}
export interface UserPromptSubmitInput {
session_id: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "UserPromptSubmit"
prompt: string
session?: {
id: string
}
hook_source?: HookSource
}
export type PermissionMode = "default" | "plan" | "acceptEdits" | "bypassPermissions"
export type HookSource = "opencode-plugin"
export interface StopInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "Stop"
stop_hook_active: boolean
todo_path?: string
hook_source?: HookSource
}
export type PermissionDecision = "allow" | "deny" | "ask"
/**
* Common JSON fields for all hook outputs (Claude Code spec)
*/
export interface HookCommonOutput {
/** If false, Claude stops entirely */
continue?: boolean
/** Message shown to user when continue=false */
stopReason?: string
/** Suppress output from transcript */
suppressOutput?: boolean
/** Warning/message displayed to user */
systemMessage?: string
}
export interface PreToolUseOutput extends HookCommonOutput {
/** Deprecated: use hookSpecificOutput.permissionDecision instead */
decision?: "allow" | "deny" | "approve" | "block" | "ask"
/** Deprecated: use hookSpecificOutput.permissionDecisionReason instead */
reason?: string
hookSpecificOutput?: {
hookEventName: "PreToolUse"
permissionDecision: PermissionDecision
permissionDecisionReason?: string
updatedInput?: Record<string, unknown>
}
}
export interface PostToolUseOutput extends HookCommonOutput {
decision?: "block"
reason?: string
hookSpecificOutput?: {
hookEventName: "PostToolUse"
/** Additional context to provide to Claude */
additionalContext?: string
}
}
export interface HookResult {
exitCode: number
stdout?: string
stderr?: string
}
export interface TranscriptEntry {
type: "tool_use" | "tool_result" | "user" | "assistant"
timestamp: string
tool_name?: string
tool_input?: Record<string, unknown>
tool_output?: Record<string, unknown>
content?: string
}
export interface TodoItem {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
created_at: string
updated_at?: string
}
export interface ClaudeCodeTodoItem {
content: string
status: string // "pending" | "in_progress" | "completed"
activeForm: string
}
export interface TodoFile {
session_id: string
items: TodoItem[]
created_at: string
updated_at: string
}
export interface StopOutput {
decision?: "block" | "continue"
reason?: string
stop_hook_active?: boolean
permission_mode?: PermissionMode
inject_prompt?: string
}
export type ClaudeCodeContent =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; tool_use_id: string; content: string }
export interface ClaudeCodeMessage {
type: "user" | "assistant"
message: {
role: "user" | "assistant"
content: ClaudeCodeContent[]
}
}
export interface PluginConfig {
disabledHooks?: boolean | ClaudeHookEvent[]
}

View File

@@ -0,0 +1,117 @@
import type {
UserPromptSubmitInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
export interface MessagePart {
type: "text" | "tool_use" | "tool_result"
text?: string
[key: string]: unknown
}
export interface UserPromptSubmitContext {
sessionId: string
parentSessionId?: string
prompt: string
parts: MessagePart[]
cwd: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
}
export interface UserPromptSubmitResult {
block: boolean
reason?: string
modifiedParts: MessagePart[]
messages: string[]
}
export async function executeUserPromptSubmitHooks(
ctx: UserPromptSubmitContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<UserPromptSubmitResult> {
const modifiedParts = ctx.parts
const messages: string[] = []
if (ctx.parentSessionId) {
return { block: false, modifiedParts, messages }
}
if (
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
) {
return { block: false, modifiedParts, messages }
}
if (!config) {
return { block: false, modifiedParts, messages }
}
const matchers = findMatchingHooks(config, "UserPromptSubmit")
if (matchers.length === 0) {
return { block: false, modifiedParts, messages }
}
const stdinData: UserPromptSubmitInput = {
session_id: ctx.sessionId,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "UserPromptSubmit",
prompt: ctx.prompt,
session: { id: ctx.sessionId },
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.stdout) {
const output = result.stdout.trim()
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
messages.push(output)
} else {
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
}
}
if (result.exitCode !== 0) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
modifiedParts,
messages,
}
}
} catch {
// Ignore JSON parse errors
}
}
}
}
return { block: false, modifiedParts, messages }
}

View File

@@ -7,16 +7,8 @@ const CONTEXT_WARNING_THRESHOLD = 0.70
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
You are using Anthropic Claude with 1M context window.
Current usage has exceeded 75%.
RECOMMENDATIONS:
- Consider compacting the session if available
- Break complex tasks into smaller, focused sessions
- Be concise in your responses
- Avoid redundant file reads
You have access to 1M tokens - use them wisely. Do NOT rush or skip tasks.
Complete your work thoroughly despite the context usage warning.`
You have plenty of context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
interface AssistantMessageInfo {
role: "assistant"

View File

@@ -0,0 +1,9 @@
import { join } from "node:path";
import { xdgData } from "xdg-basedir";
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
export const README_INJECTOR_STORAGE = join(
OPENCODE_STORAGE,
"directory-readme",
);
export const README_FILENAME = "README.md";

View File

@@ -0,0 +1,126 @@
import type { PluginInput } from "@opencode-ai/plugin";
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import {
loadInjectedPaths,
saveInjectedPaths,
clearInjectedPaths,
} from "./storage";
import { README_FILENAME } from "./constants";
interface ToolExecuteInput {
tool: string;
sessionID: string;
callID: string;
}
interface ToolExecuteOutput {
title: string;
output: string;
metadata: unknown;
}
interface EventInput {
event: {
type: string;
properties?: unknown;
};
}
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
const sessionCaches = new Map<string, Set<string>>();
function getSessionCache(sessionID: string): Set<string> {
if (!sessionCaches.has(sessionID)) {
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
}
return sessionCaches.get(sessionID)!;
}
function resolveFilePath(title: string): string | null {
if (!title) return null;
if (title.startsWith("/")) return title;
return resolve(ctx.directory, title);
}
function findReadmeMdUp(startDir: string): string[] {
const found: string[] = [];
let current = startDir;
while (true) {
const readmePath = join(current, README_FILENAME);
if (existsSync(readmePath)) {
found.push(readmePath);
}
if (current === ctx.directory) break;
const parent = dirname(current);
if (parent === current) break;
if (!parent.startsWith(ctx.directory)) break;
current = parent;
}
return found.reverse();
}
const toolExecuteAfter = async (
input: ToolExecuteInput,
output: ToolExecuteOutput,
) => {
if (input.tool.toLowerCase() !== "read") return;
const filePath = resolveFilePath(output.title);
if (!filePath) return;
const dir = dirname(filePath);
const cache = getSessionCache(input.sessionID);
const readmePaths = findReadmeMdUp(dir);
const toInject: { path: string; content: string }[] = [];
for (const readmePath of readmePaths) {
const readmeDir = dirname(readmePath);
if (cache.has(readmeDir)) continue;
try {
const content = readFileSync(readmePath, "utf-8");
toInject.push({ path: readmePath, content });
cache.add(readmeDir);
} catch {}
}
if (toInject.length === 0) return;
for (const { path, content } of toInject) {
output.output += `\n\n[Project README: ${path}]\n${content}`;
}
saveInjectedPaths(input.sessionID, cache);
};
const eventHandler = async ({ event }: EventInput) => {
const props = event.properties as Record<string, unknown> | undefined;
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id) {
sessionCaches.delete(sessionInfo.id);
clearInjectedPaths(sessionInfo.id);
}
}
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ??
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
if (sessionID) {
sessionCaches.delete(sessionID);
clearInjectedPaths(sessionID);
}
}
};
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
};
}

View File

@@ -0,0 +1,48 @@
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
} from "node:fs";
import { join } from "node:path";
import { README_INJECTOR_STORAGE } from "./constants";
import type { InjectedPathsData } from "./types";
function getStoragePath(sessionID: string): string {
return join(README_INJECTOR_STORAGE, `${sessionID}.json`);
}
export function loadInjectedPaths(sessionID: string): Set<string> {
const filePath = getStoragePath(sessionID);
if (!existsSync(filePath)) return new Set();
try {
const content = readFileSync(filePath, "utf-8");
const data: InjectedPathsData = JSON.parse(content);
return new Set(data.injectedPaths);
} catch {
return new Set();
}
}
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
if (!existsSync(README_INJECTOR_STORAGE)) {
mkdirSync(README_INJECTOR_STORAGE, { recursive: true });
}
const data: InjectedPathsData = {
sessionID,
injectedPaths: [...paths],
updatedAt: Date.now(),
};
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
}
export function clearInjectedPaths(sessionID: string): void {
const filePath = getStoragePath(sessionID);
if (existsSync(filePath)) {
unlinkSync(filePath);
}
}

View File

@@ -0,0 +1,5 @@
export interface InjectedPathsData {
sessionID: string;
injectedPaths: string[];
updatedAt: number;
}

View File

@@ -5,6 +5,8 @@ export { createSessionRecoveryHook } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";

View File

@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import {
findEmptyMessages,
findEmptyMessageByIndex,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
injectTextPart,
@@ -54,6 +55,12 @@ function getErrorMessage(error: unknown): string {
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
}
function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
@@ -161,16 +168,26 @@ async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string
_directory: string,
error: unknown
): Promise<boolean> {
const emptyMessageIDs = findEmptyMessages(sessionID)
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
if (emptyMessageIDs.length === 0) {
const fallbackID = failedAssistantMsg.info?.id
if (!fallbackID) return false
return injectTextPart(sessionID, fallbackID, "(interrupted)")
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
return injectTextPart(sessionID, targetMessageID, "(interrupted)")
}
}
if (failedID) {
if (injectTextPart(sessionID, failedID, "(interrupted)")) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
let anySuccess = false
for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
@@ -262,15 +279,16 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
} else if (errorType === "empty_content_message") {
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
}
return success
} catch {
return false
} finally {
processingErrors.delete(assistantMsgID)
}
return success
} catch (err) {
console.error("[session-recovery] Recovery failed:", err)
return false
} finally {
processingErrors.delete(assistantMsgID)
}
}
return {

View File

@@ -42,7 +42,12 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
}
}
return messages.sort((a, b) => a.id.localeCompare(b.id))
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
export function readParts(messageID: string): StoredPart[] {
@@ -117,13 +122,9 @@ export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID)
const emptyIds: string[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
for (const msg of messages) {
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
@@ -132,6 +133,18 @@ export function findEmptyMessages(sessionID: string): string[] {
return emptyIds
}
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMsg = messages[targetIndex]
if (targetMsg.role !== "assistant") return null
if (messageHasContent(targetMsg.id)) return null
return targetMsg.id
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null

View File

@@ -1,19 +1,52 @@
// Maps model IDs to their "high reasoning" variant (internal convention)
// For OpenAI models, this signals that reasoning_effort should be set to "high"
const HIGH_VARIANT_MAP: Record<string, string> = {
// Claude
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
"claude-opus-4-5": "claude-opus-4-5-high",
"gpt-5.1": "gpt-5.1-high",
"gpt-5.1-medium": "gpt-5.1-high",
"gpt-5.1-codex": "gpt-5.1-codex-high",
// Gemini
"gemini-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high",
// GPT-5
"gpt-5": "gpt-5-high",
"gpt-5-mini": "gpt-5-mini-high",
"gpt-5-nano": "gpt-5-nano-high",
"gpt-5-pro": "gpt-5-pro-high",
"gpt-5-chat-latest": "gpt-5-chat-latest-high",
// GPT-5.1
"gpt-5.1": "gpt-5.1-high",
"gpt-5.1-chat-latest": "gpt-5.1-chat-latest-high",
"gpt-5.1-codex": "gpt-5.1-codex-high",
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini-high",
"gpt-5.1-codex-max": "gpt-5.1-codex-max-high",
// GPT-5.2
"gpt-5.2": "gpt-5.2-high",
"gpt-5.2-chat-latest": "gpt-5.2-chat-latest-high",
"gpt-5.2-pro": "gpt-5.2-pro-high",
}
const ALREADY_HIGH: Set<string> = new Set([
// Claude
"claude-sonnet-4-5-high",
"claude-opus-4-5-high",
"gpt-5.1-high",
"gpt-5.1-codex-high",
// Gemini
"gemini-3-pro-high",
// GPT-5
"gpt-5-high",
"gpt-5-mini-high",
"gpt-5-nano-high",
"gpt-5-pro-high",
"gpt-5-chat-latest-high",
// GPT-5.1
"gpt-5.1-high",
"gpt-5.1-chat-latest-high",
"gpt-5.1-codex-high",
"gpt-5.1-codex-mini-high",
"gpt-5.1-codex-max-high",
// GPT-5.2
"gpt-5.2-high",
"gpt-5.2-chat-latest-high",
"gpt-5.2-pro-high",
])
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {

View File

@@ -36,6 +36,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
@@ -47,6 +48,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
if (detectInterrupt(props?.error)) {
interruptedSessions.add(sessionID)
}
// Cancel pending continuation if error occurs
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
}
return
}
@@ -55,68 +63,78 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
// Wait for potential session.error events to be processed first
await new Promise(resolve => setTimeout(resolve, 150))
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
if (shouldBypass) {
return
// Cancel any existing timer to debounce
const existingTimer = pendingTimers.get(sessionID)
if (existingTimer) {
clearTimeout(existingTimer)
}
if (remindedSessions.has(sessionID)) {
return
}
// Schedule continuation check
const timer = setTimeout(async () => {
pendingTimers.delete(sessionID)
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
if (!todos || todos.length === 0) {
return
}
if (shouldBypass) {
return
}
const incomplete = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (remindedSessions.has(sessionID)) {
return
}
if (incomplete.length === 0) {
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
remindedSessions.add(sessionID)
if (!todos || todos.length === 0) {
return
}
// Re-check if abort occurred during the delay
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
}
const incomplete = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
},
],
},
query: { directory: ctx.directory },
})
} catch {
remindedSessions.delete(sessionID)
}
if (incomplete.length === 0) {
return
}
remindedSessions.add(sessionID)
// Re-check if abort occurred during the delay/fetch
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
}
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
},
],
},
query: { directory: ctx.directory },
})
} catch {
remindedSessions.delete(sessionID)
}
}, 200)
pendingTimers.set(sessionID, timer)
}
if (event.type === "message.updated") {
@@ -124,6 +142,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "user") {
remindedSessions.delete(sessionID)
// Cancel pending continuation on user interaction
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
}
}
@@ -133,6 +158,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id)
// Cancel pending continuation
const timer = pendingTimers.get(sessionInfo.id)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionInfo.id)
}
}
}
}

View File

@@ -7,8 +7,11 @@ import {
createCommentCheckerHooks,
createGrepOutputTruncatorHook,
createDirectoryAgentsInjectorHook,
createDirectoryReadmeInjectorHook,
createEmptyTaskResponseDetectorHook,
createThinkModeHook,
createClaudeCodeHooksHook,
createAnthropicAutoCompactHook,
} from "./hooks";
import {
loadUserCommands,
@@ -38,84 +41,167 @@ import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { log } from "./shared/logger";
import * as fs from "fs";
import * as path from "path";
import * as os from "os";
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
const configPaths = [
path.join(directory, "oh-my-opencode.json"),
path.join(directory, ".oh-my-opencode.json"),
];
for (const configPath of configPaths) {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = JSON.parse(content);
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
log(`Config validation error in ${configPath}:`, result.error.issues);
return {};
}
return result.data;
}
} catch {
// Ignore parse errors, use defaults
}
/**
* Returns the user-level config directory based on the OS.
* - Linux/macOS: XDG_CONFIG_HOME or ~/.config
* - Windows: %APPDATA%
*/
function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
}
return {};
// Linux, macOS, and other Unix-like systems: respect XDG_CONFIG_HOME
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
}
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = JSON.parse(content);
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
log(`Config validation error in ${configPath}:`, result.error.issues);
return null;
}
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
return result.data;
}
} catch (err) {
log(`Error loading config from ${configPath}:`, err);
}
return null;
}
function mergeConfigs(
base: OhMyOpenCodeConfig,
override: OhMyOpenCodeConfig
): OhMyOpenCodeConfig {
return {
...base,
...override,
agents:
override.agents !== undefined
? { ...(base.agents ?? {}), ...override.agents }
: base.agents,
disabled_agents: [
...new Set([
...(base.disabled_agents ?? []),
...(override.disabled_agents ?? []),
]),
],
disabled_mcps: [
...new Set([
...(base.disabled_mcps ?? []),
...(override.disabled_mcps ?? []),
]),
],
claude_code:
override.claude_code !== undefined || base.claude_code !== undefined
? { ...(base.claude_code ?? {}), ...(override.claude_code ?? {}) }
: undefined,
};
}
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
// User-level config path (OS-specific)
const userConfigPath = path.join(
getUserConfigDir(),
"opencode",
"oh-my-opencode.json"
);
// Project-level config path
const projectConfigPath = path.join(
directory,
".opencode",
"oh-my-opencode.json"
);
// Load user config first (base)
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath) ?? {};
// Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath);
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
log("Final merged config", {
agents: config.agents,
disabled_agents: config.disabled_agents,
disabled_mcps: config.disabled_mcps,
claude_code: config.claude_code,
});
return config;
}
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx);
const commentChecker = createCommentCheckerHooks();
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const directoryReadmeInjector = createDirectoryReadmeInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
const thinkMode = createThinkModeHook();
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
});
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
updateTerminalTitle({ sessionId: "main" });
const pluginConfig = loadPluginConfig(ctx.directory);
return {
tool: builtinTools,
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output)
},
config: async (config) => {
const builtinAgents = createBuiltinAgents(
pluginConfig.disabled_agents,
pluginConfig.agents,
);
const userAgents = loadUserAgents();
const projectAgents = loadProjectAgents();
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
config.agent = {
...config.agent,
...builtinAgents,
...userAgents,
...projectAgents,
...config.agent,
};
config.tools = {
...config.tools,
};
const mcpResult = await loadMcpConfigs();
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
? await loadMcpConfigs()
: { servers: {} };
config.mcp = {
...config.mcp,
...createBuiltinMcps(pluginConfig.disabled_mcps),
...mcpResult.servers,
};
const userCommands = loadUserCommands();
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
const systemCommands = config.command ?? {};
const projectCommands = loadProjectCommands();
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
const opencodeProjectCommands = loadOpencodeProjectCommands();
const userSkills = loadUserSkillsAsCommands();
const projectSkills = loadProjectSkillsAsCommands();
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkillsAsCommands() : {};
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkillsAsCommands() : {};
config.command = {
...userCommands,
@@ -129,10 +215,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
event: async (input) => {
await claudeCodeHooks.event(input);
await todoContinuationEnforcer(input);
await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input);
await directoryReadmeInjector.event(input);
await thinkMode.event(input);
await anthropicAutoCompact.event(input);
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
@@ -229,6 +318,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.before": async (input, output) => {
await claudeCodeHooks["tool.execute.before"](input, output);
await commentChecker["tool.execute.before"](input, output);
if (input.sessionID === getMainSessionID()) {
@@ -243,10 +333,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.after": async (input, output) => {
await claudeCodeHooks["tool.execute.after"](input, output);
await grepOutputTruncator["tool.execute.after"](input, output);
await contextWindowMonitor["tool.execute.after"](input, output);
await commentChecker["tool.execute.after"](input, output);
await directoryAgentsInjector["tool.execute.after"](input, output);
await directoryReadmeInjector["tool.execute.after"](input, output);
await emptyTaskResponseDetector["tool.execute.after"](input, output);
if (input.sessionID === getMainSessionID()) {

View File

@@ -0,0 +1,22 @@
import type { ClaudeHookEvent, PluginConfig } from "../hooks/claude-code-hooks/types"
export function isHookDisabled(
config: PluginConfig,
hookType: ClaudeHookEvent
): boolean {
const { disabledHooks } = config
if (disabledHooks === undefined) {
return false
}
if (disabledHooks === true) {
return true
}
if (Array.isArray(disabledHooks)) {
return disabledHooks.includes(hookType)
}
return false
}

View File

@@ -3,3 +3,7 @@ export * from "./command-executor"
export * from "./file-reference-resolver"
export * from "./model-sanitizer"
export * from "./logger"
export * from "./snake-case"
export * from "./tool-name"
export * from "./pattern-matcher"
export * from "./hook-disabled"

View File

@@ -0,0 +1,29 @@
import type { ClaudeHooksConfig, HookMatcher } from "../hooks/claude-code-hooks/types"
export function matchesToolMatcher(toolName: string, matcher: string): boolean {
if (!matcher) {
return true
}
const patterns = matcher.split("|").map((p) => p.trim())
return patterns.some((p) => {
if (p.includes("*")) {
const regex = new RegExp(`^${p.replace(/\*/g, ".*")}$`, "i")
return regex.test(toolName)
}
return p.toLowerCase() === toolName.toLowerCase()
})
}
export function findMatchingHooks(
config: ClaudeHooksConfig,
eventName: keyof ClaudeHooksConfig,
toolName?: string
): HookMatcher[] {
const hookMatchers = config[eventName]
if (!hookMatchers) return []
return hookMatchers.filter((hookMatcher) => {
if (!toolName) return true
return matchesToolMatcher(toolName, hookMatcher.matcher)
})
}

51
src/shared/snake-case.ts Normal file
View File

@@ -0,0 +1,51 @@
export function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
export function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function objectToSnakeCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const snakeKey = camelToSnake(key)
if (deep && isPlainObject(value)) {
result[snakeKey] = objectToSnakeCase(value, true)
} else if (deep && Array.isArray(value)) {
result[snakeKey] = value.map((item) =>
isPlainObject(item) ? objectToSnakeCase(item, true) : item
)
} else {
result[snakeKey] = value
}
}
return result
}
export function objectToCamelCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const camelKey = snakeToCamel(key)
if (deep && isPlainObject(value)) {
result[camelKey] = objectToCamelCase(value, true)
} else if (deep && Array.isArray(value)) {
result[camelKey] = value.map((item) =>
isPlainObject(item) ? objectToCamelCase(item, true) : item
)
} else {
result[camelKey] = value
}
}
return result
}

26
src/shared/tool-name.ts Normal file
View File

@@ -0,0 +1,26 @@
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
webfetch: "WebFetch",
websearch: "WebSearch",
todoread: "TodoRead",
todowrite: "TodoWrite",
}
function toPascalCase(str: string): string {
return str
.split(/[-_\s]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("")
}
export function transformToolName(toolName: string): string {
const lower = toolName.toLowerCase()
if (lower in SPECIAL_TOOL_MAPPINGS) {
return SPECIAL_TOOL_MAPPINGS[lower]
}
if (toolName.includes("-") || toolName.includes("_")) {
return toPascalCase(toolName)
}
return toolName.charAt(0).toUpperCase() + toolName.slice(1)
}

View File

@@ -2,8 +2,23 @@ import { tool } from "@opencode-ai/plugin"
import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, resolve, basename } from "path"
import { z } from "zod/v4"
import { parseFrontmatter, resolveCommandsInText } from "../../shared"
import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill } from "./types"
import { SkillFrontmatterSchema } from "./types"
import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill, SkillFrontmatter } from "./types"
function parseSkillFrontmatter(data: Record<string, unknown>): SkillFrontmatter {
return {
name: typeof data.name === "string" ? data.name : "",
description: typeof data.description === "string" ? data.description : "",
license: typeof data.license === "string" ? data.license : undefined,
"allowed-tools": Array.isArray(data["allowed-tools"]) ? data["allowed-tools"] : undefined,
metadata:
typeof data.metadata === "object" && data.metadata !== null
? (data.metadata as Record<string, string>)
: undefined,
}
}
function discoverSkillsFromDir(
skillsDir: string,
@@ -93,10 +108,14 @@ async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
content = await resolveCommandsInText(content)
const { data, body } = parseFrontmatter(content)
const frontmatter = parseSkillFrontmatter(data)
const metadata: SkillMetadata = {
name: data.name || basename(skillPath),
description: data.description || "",
license: data.license,
name: frontmatter.name || basename(skillPath),
description: frontmatter.description,
license: frontmatter.license,
allowedTools: frontmatter["allowed-tools"],
metadata: frontmatter.metadata,
}
const referencesDir = join(resolvedPath, "references")
@@ -118,6 +137,7 @@ async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
return {
name: metadata.name,
path: resolvedPath,
basePath: resolvedPath,
metadata,
content: body,
references,
@@ -202,6 +222,7 @@ async function loadSkillWithReferences(
content = await resolveCommandsInText(content)
referencesLoaded.push({ path: ref, content })
} catch {
// Skip unreadable references
}
}
}
@@ -209,6 +230,7 @@ async function loadSkillWithReferences(
return {
name: skill.name,
metadata: skill.metadata,
basePath: skill.basePath,
body: skill.content,
referencesLoaded,
}
@@ -234,31 +256,24 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
return "No skills loaded."
}
const sections: string[] = ["# Loaded Skills\n"]
const skill = loadedSkills[0]
const sections: string[] = []
for (const skill of loadedSkills) {
sections.push(`## ${skill.metadata.name}\n`)
sections.push(`**Description**: ${skill.metadata.description || "(no description)"}\n`)
sections.push("### Skill Instructions\n")
sections.push(skill.body.trim())
sections.push(`Base directory for this skill: ${skill.basePath}/`)
sections.push("")
sections.push(skill.body.trim())
if (skill.referencesLoaded.length > 0) {
sections.push("\n### Loaded References\n")
for (const ref of skill.referencesLoaded) {
sections.push(`#### ${ref.path}\n`)
sections.push("```")
sections.push(ref.content.trim())
sections.push("```\n")
}
if (skill.referencesLoaded.length > 0) {
sections.push("\n---\n### Loaded References\n")
for (const ref of skill.referencesLoaded) {
sections.push(`#### ${ref.path}\n`)
sections.push("```")
sections.push(ref.content.trim())
sections.push("```\n")
}
sections.push("\n---\n")
}
const skillNames = loadedSkills.map((s) => s.metadata.name).join(", ")
sections.push(`**Skills loaded**: ${skillNames}`)
sections.push(`**Total**: ${loadedSkills.length} skill(s)`)
sections.push("\nPlease confirm these skills match your needs before proceeding.")
sections.push(`\n---\n**Launched skill**: ${skill.metadata.name}`)
return sections.join("\n")
}
@@ -266,25 +281,7 @@ function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
export const skill = tool({
description: `Execute a skill within the main conversation.
When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge.
How to use skills:
- Invoke skills using this tool with the skill name only (no arguments)
- When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task
Important:
- Only use skills listed in Available Skills below
- Do not invoke a skill that is already running
Skills are loaded from:
- ~/.claude/skills/ (user scope - global skills)
- ./.claude/skills/ (project scope - project-specific skills)
Each skill contains:
- SKILL.md: Main instructions with YAML frontmatter (name, description)
- references/: Documentation files loaded into context as needed
- scripts/: Executable code for deterministic operations
- assets/: Files used in output (templates, icons, etc.)
When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task.
Available Skills:
${skillListForDescription}`,

View File

@@ -1,14 +1,36 @@
import { z } from "zod/v4"
export type SkillScope = "user" | "project"
/**
* Zod schema for skill frontmatter validation
* Following Anthropic Agent Skills Specification v1.0
*/
export const SkillFrontmatterSchema = z.object({
name: z
.string()
.regex(/^[a-z0-9-]+$/, "Name must be lowercase alphanumeric with hyphens only")
.min(1, "Name cannot be empty"),
description: z.string().min(20, "Description must be at least 20 characters for discoverability"),
license: z.string().optional(),
"allowed-tools": z.array(z.string()).optional(),
metadata: z.record(z.string(), z.string()).optional(),
})
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>
export interface SkillMetadata {
name: string
description: string
license?: string
allowedTools?: string[]
metadata?: Record<string, string>
}
export interface SkillInfo {
name: string
path: string
basePath: string
metadata: SkillMetadata
content: string
references: string[]
@@ -19,6 +41,7 @@ export interface SkillInfo {
export interface LoadedSkill {
name: string
metadata: SkillMetadata
basePath: string
body: string
referencesLoaded: Array<{ path: string; content: string }>
}

View File

@@ -1,4 +0,0 @@
## Root Level Rules
- Root rule 1
- Root rule 2

View File

@@ -1,3 +0,0 @@
export const config = {
strict: true
}

View File

@@ -1,4 +0,0 @@
## Nested Level Rules
- Nested rule 1 (더 specific)
- Nested rule 2

View File

@@ -1 +0,0 @@
export const deep = true

View File

@@ -1,3 +0,0 @@
export function greet(name: string): string {
return `Hello, ${name}!`
}

View File

@@ -1,6 +0,0 @@
id: test-rule
message: Test rule
severity: info
language: JavaScript
rule:
pattern: console

View File

@@ -1 +0,0 @@
console.log("hello")