Compare commits

...

28 Commits

Author SHA1 Message Date
github-actions[bot]
f0193a7e45 release: v0.2.0 2025-12-09 12:48:32 +00:00
github-actions[bot]
bedbc621fa release: v0.1.32 2025-12-09 08:00:31 +00:00
YeonGyu-Kim
e147be7ed4 feat: update comment-checker to v0.5.0
BREAKING CHANGE: Docstrings are now detected as code smell.
See: https://github.com/code-yeongyu/go-claude-code-comment-checker/releases/tag/v0.5.0

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
124c3b3e8f docs: fix documentation inconsistencies identified by Oracle
- Add source path annotations to Command/Skill/Agent/MCP Loaders
- Add Session State feature documentation
- MCP Loader paths verified (match loader.ts: ~/.claude/.mcp.json, ./.mcp.json, ./.claude/.mcp.json)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
5678e0bac6 docs(readme): document new claude-code feature loaders
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
207435450c refactor(index): integrate session-state module and remove local variables
- Add imports: agent-loader, mcp-loader, session-state, logger
- Remove local session variables (mainSessionID, currentSessionID, currentSessionTitle)
- Use setter/getter functions from session-state module
- Add agent loading in config hook (loadUserAgents, loadProjectAgents)
- Add MCP loading in config hook (loadMcpConfigs)
- Replace console.error with logger

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
376bf363af feat(features): add claude-code-agent-loader, mcp-loader, session-state 2025-12-09 16:59:22 +09:00
YeonGyu-Kim
c7a65af475 refactor(features): rename command-loader and skill-loader with claude-code prefix 2025-12-09 16:59:22 +09:00
YeonGyu-Kim
8e7447deee feat(shared): add file-based logger utility 2025-12-09 16:59:22 +09:00
YeonGyu-Kim
15a748b817 docs: add missing hooks and features to README
Add 4 missing hooks documentation:
- Think Mode: auto-detect deep thinking requests
- Anthropic Auto Compact: auto-compact context
- Empty Task Response Detector: handle empty responses
- Grep Output Truncator: prevent output overflow

Add 2 missing features documentation:
- Command Loader: load commands from multiple paths
- Skill Loader: load skills as executable commands

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
c0e0dc1f95 feat: integrate command/skill loaders and think-mode hook in main entry
- Add loadCommands() and loadSkills() to config
- Register think-mode hook for UserPromptSubmit event

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
7059407cbc feat(hooks): export createThinkModeHook from index
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
589cf60252 feat(hooks): add think-mode hook for automatic model switching
Detects thinking keywords (ultrathink, deepthink, etc.) and switches
to thinking-capable models automatically.

Supports model patterns:
- claude-sonnet-4-0 -> claude-sonnet-4-0-max-thinking
- claude-sonnet-4-20250514 -> claude-sonnet-4-20250514-max-thinking

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
e5cdaa5192 feat(tools): export slashcommand and skill tools from index
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
340eb30147 feat(tools): add skill tool for invoking skills in conversation
Provides 'skill' tool that invokes skills loaded by skill-loader.
Skills expand into detailed instructions when invoked.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
e72b927ccb feat(tools): add slashcommand tool for executing slash commands
Provides 'slashcommand' tool that executes commands loaded by command-loader.
Handles shell injection and file reference resolution.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
3c6ffe5d9c feat(skill-loader): add skill loader that converts skills to commands
Skills are loaded from:
- ~/.claude/skills/ (user scope)
- .claude/skills/ (project scope)

Each skill directory contains SKILL.md with frontmatter metadata.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
938a3709e1 feat(command-loader): add slash command loader from multiple paths
Load commands from 4 directory scopes:
- .opencode/command/ (opencode-project)
- .claude/commands/ (project)
- ~/.config/opencode/command/ (opencode)
- ~/.claude/commands/ (user)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
YeonGyu-Kim
47f218e33f feat(shared): add shared utilities for command and skill loading
- frontmatter.ts: YAML frontmatter parser
- file-reference-resolver.ts: resolve @file references in markdown
- command-executor.ts: execute shell commands in markdown
- model-sanitizer.ts: sanitize model names for OpenCode compatibility

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 16:59:22 +09:00
github-actions[bot]
e07a25baa4 release: v0.1.31 2025-12-09 05:42:03 +00:00
YeonGyu-Kim
08ede0a28d deps: bump @code-yeongyu/comment-checker to 0.4.4
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 14:41:07 +09:00
github-actions[bot]
a711d58289 release: v0.1.30 2025-12-09 02:50:19 +00:00
YeonGyu-Kim
431ec14991 docs: update notepad with cleanup task logs
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:49:17 +09:00
YeonGyu-Kim
62cae8114d refactor(comment-checker): simplify binary path resolution and add separator warning
- Remove platform-specific package lookup logic
- Remove homebrew path resolution
- Add code smell warning for comment separators

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:49:11 +09:00
YeonGyu-Kim
e6eafe267a refactor(ast-grep): remove NAPI-based tools
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:49:00 +09:00
YeonGyu-Kim
e4ef832405 feat(hooks): add anthropic-auto-compact hook
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:48:53 +09:00
YeonGyu-Kim
ef6d67645e refactor(hooks): remove pulse-monitor hook
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 11:48:46 +09:00
YeonGyu-Kim
227d93f106 docs(readme-ko): clarify nested AGENTS.md injection behavior with example
Added directory tree example showing how multiple AGENTS.md files are
collected and injected in hierarchical order when reading nested files.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 10:38:05 +09:00
50 changed files with 2641 additions and 331 deletions

View File

@@ -142,7 +142,21 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 디렉토리별 컨텍스트를 에이전트에게 제공합니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
```
project/
├── AGENTS.md # 프로젝트 전체 컨텍스트
├── src/
│ ├── AGENTS.md # src 전용 컨텍스트
│ └── components/
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
```
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
### Agents
@@ -211,7 +225,16 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
```
### 기타 편의 기능
- **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/`
## 설정

View File

@@ -149,6 +149,10 @@ 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.
- **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.
@@ -220,6 +224,26 @@ Don't need these? Disable them via `oh-my-opencode.json`:
### 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`.
## Configuration

View File

@@ -7,13 +7,14 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@code-yeongyu/comment-checker": "^0.4.1",
"@code-yeongyu/comment-checker": "^0.4.4",
"@opencode-ai/plugin": "^1.0.7",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8",
},
"devDependencies": {
"bun-types": "latest",
"oh-my-opencode": "^0.1.30",
"typescript": "^5.7.3",
},
"peerDependencies": {
@@ -63,7 +64,7 @@
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.4", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-vsbdLMQYJJNDV/baTDnNqqg/MZwA+9nz7TE6Mybj8zjZVTCn4ZivH4hAdD5p4fLxhGZEJ5x1UDmXA6pAGA7lHA=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
@@ -99,6 +100,8 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -106,5 +109,7 @@
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
}
}

View File

@@ -59,3 +59,340 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
---
## [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.29",
"version": "0.2.0",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -44,13 +44,14 @@
"dependencies": {
"@ast-grep/cli": "^0.40.0",
"@ast-grep/napi": "^0.40.0",
"@code-yeongyu/comment-checker": "^0.4.1",
"@code-yeongyu/comment-checker": "^0.5.0",
"@opencode-ai/plugin": "^1.0.7",
"xdg-basedir": "^5.1.0",
"zod": "^4.1.8"
},
"devDependencies": {
"bun-types": "latest",
"oh-my-opencode": "^0.1.30",
"typescript": "^5.7.3"
},
"peerDependencies": {

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./loader"

View File

@@ -0,0 +1,93 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import type { AgentConfig } from "@opencode-ai/sdk"
import { parseFrontmatter } from "../../shared/frontmatter"
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
if (!toolsStr) return undefined
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
if (tools.length === 0) return undefined
const result: Record<string, boolean> = {}
for (const tool of tools) {
result[tool.toLowerCase()] = true
}
return result
}
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
}
function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] {
if (!existsSync(agentsDir)) {
return []
}
const entries = readdirSync(agentsDir, { withFileTypes: true })
const agents: LoadedAgent[] = []
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const agentPath = join(agentsDir, entry.name)
const agentName = basename(entry.name, ".md")
try {
const content = readFileSync(agentPath, "utf-8")
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
const name = data.name || agentName
const originalDescription = data.description || ""
const formattedDescription = `(${scope}) ${originalDescription}`
const config: AgentConfig = {
description: formattedDescription,
mode: "subagent",
prompt: body.trim(),
}
const toolsConfig = parseToolsConfig(data.tools)
if (toolsConfig) {
config.tools = toolsConfig
}
agents.push({
name,
path: agentPath,
config,
scope,
})
} catch {
continue
}
}
return agents
}
export function loadUserAgents(): Record<string, AgentConfig> {
const userAgentsDir = join(homedir(), ".claude", "agents")
const agents = loadAgentsFromDir(userAgentsDir, "user")
const result: Record<string, AgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}
export function loadProjectAgents(): Record<string, AgentConfig> {
const projectAgentsDir = join(process.cwd(), ".claude", "agents")
const agents = loadAgentsFromDir(projectAgentsDir, "project")
const result: Record<string, AgentConfig> = {}
for (const agent of agents) {
result[agent.name] = agent.config
}
return result
}

View File

@@ -0,0 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentScope = "user" | "project"
export interface AgentFrontmatter {
name?: string
description?: string
model?: string
tools?: string
}
export interface LoadedAgent {
name: string
path: string
config: AgentConfig
scope: AgentScope
}

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./loader"

View File

@@ -0,0 +1,94 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
}
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
if (!existsSync(commandsDir)) {
return []
}
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: LoadedCommand[] = []
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
try {
const content = readFileSync(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const formattedDescription = `(${scope}) ${data.description || ""}`
const definition: CommandDefinition = {
name: commandName,
description: formattedDescription,
template: wrappedTemplate,
agent: data.agent,
model: sanitizeModelField(data.model),
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
commands.push({
name: commandName,
path: commandPath,
definition,
scope,
})
} catch {
continue
}
}
return commands
}
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const cmd of commands) {
result[cmd.name] = cmd.definition
}
return result
}
export function loadUserCommands(): Record<string, CommandDefinition> {
const userCommandsDir = join(homedir(), ".claude", "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
export function loadProjectCommands(): Record<string, CommandDefinition> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = loadCommandsFromDir(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}

View File

@@ -0,0 +1,26 @@
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
export interface CommandDefinition {
name: string
description?: string
template: string
agent?: string
model?: string
subtask?: boolean
argumentHint?: string
}
export interface CommandFrontmatter {
description?: string
"argument-hint"?: string
agent?: string
model?: string
subtask?: boolean
}
export interface LoadedCommand {
name: string
path: string
definition: CommandDefinition
scope: CommandScope
}

View File

@@ -0,0 +1,27 @@
export function expandEnvVars(value: string): string {
return value.replace(
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
(_, varName: string, defaultValue?: string) => {
const envValue = process.env[varName]
if (envValue !== undefined) return envValue
if (defaultValue !== undefined) return defaultValue
return ""
}
)
}
export function expandEnvVarsInObject<T>(obj: T): T {
if (obj === null || obj === undefined) return obj
if (typeof obj === "string") return expandEnvVars(obj) as T
if (Array.isArray(obj)) {
return obj.map((item) => expandEnvVarsInObject(item)) as T
}
if (typeof obj === "object") {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
result[key] = expandEnvVarsInObject(value)
}
return result as T
}
return obj
}

View File

@@ -0,0 +1,11 @@
/**
* MCP Configuration Loader
*
* Loads Claude Code .mcp.json format configurations from multiple scopes
* and transforms them to OpenCode SDK format
*/
export * from "./types"
export * from "./loader"
export * from "./transformer"
export * from "./env-expander"

View File

@@ -0,0 +1,89 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type {
ClaudeCodeMcpConfig,
LoadedMcpServer,
McpLoadResult,
McpScope,
} from "./types"
import { transformMcpServer } from "./transformer"
import { log } from "../../shared/logger"
interface McpConfigPath {
path: string
scope: McpScope
}
function getMcpConfigPaths(): McpConfigPath[] {
const home = homedir()
const cwd = process.cwd()
return [
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
{ path: join(cwd, ".mcp.json"), scope: "project" },
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
]
}
async function loadMcpConfigFile(
filePath: string
): Promise<ClaudeCodeMcpConfig | null> {
if (!existsSync(filePath)) {
return null
}
try {
const content = await Bun.file(filePath).text()
return JSON.parse(content) as ClaudeCodeMcpConfig
} catch (error) {
log(`Failed to load MCP config from ${filePath}`, error)
return null
}
}
export async function loadMcpConfigs(): Promise<McpLoadResult> {
const servers: McpLoadResult["servers"] = {}
const loadedServers: LoadedMcpServer[] = []
const paths = getMcpConfigPaths()
for (const { path, scope } of paths) {
const config = await loadMcpConfigFile(path)
if (!config?.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) {
log(`Skipping disabled MCP server "${name}"`, { path })
continue
}
try {
const transformed = transformMcpServer(name, serverConfig)
servers[name] = transformed
const existingIndex = loadedServers.findIndex((s) => s.name === name)
if (existingIndex !== -1) {
loadedServers.splice(existingIndex, 1)
}
loadedServers.push({ name, scope, config: transformed })
log(`Loaded MCP server "${name}" from ${scope}`, { path })
} catch (error) {
log(`Failed to transform MCP server "${name}"`, error)
}
}
}
return { servers, loadedServers }
}
export function formatLoadedServersForToast(
loadedServers: LoadedMcpServer[]
): string {
if (loadedServers.length === 0) return ""
return loadedServers
.map((server) => `${server.name} (${server.scope})`)
.join(", ")
}

View File

@@ -0,0 +1,53 @@
import type {
ClaudeCodeMcpServer,
McpLocalConfig,
McpRemoteConfig,
McpServerConfig,
} from "./types"
import { expandEnvVarsInObject } from "./env-expander"
export function transformMcpServer(
name: string,
server: ClaudeCodeMcpServer
): McpServerConfig {
const expanded = expandEnvVarsInObject(server)
const serverType = expanded.type ?? "stdio"
if (serverType === "http" || serverType === "sse") {
if (!expanded.url) {
throw new Error(
`MCP server "${name}" requires url for type "${serverType}"`
)
}
const config: McpRemoteConfig = {
type: "remote",
url: expanded.url,
enabled: true,
}
if (expanded.headers && Object.keys(expanded.headers).length > 0) {
config.headers = expanded.headers
}
return config
}
if (!expanded.command) {
throw new Error(`MCP server "${name}" requires command for stdio type`)
}
const commandArray = [expanded.command, ...(expanded.args ?? [])]
const config: McpLocalConfig = {
type: "local",
command: commandArray,
enabled: true,
}
if (expanded.env && Object.keys(expanded.env).length > 0) {
config.environment = expanded.env
}
return config
}

View File

@@ -0,0 +1,42 @@
export type McpScope = "user" | "project" | "local"
export interface ClaudeCodeMcpServer {
type?: "http" | "sse" | "stdio"
url?: string
command?: string
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
disabled?: boolean
}
export interface ClaudeCodeMcpConfig {
mcpServers?: Record<string, ClaudeCodeMcpServer>
}
export interface McpLocalConfig {
type: "local"
command: string[]
environment?: Record<string, string>
enabled?: boolean
}
export interface McpRemoteConfig {
type: "remote"
url: string
headers?: Record<string, string>
enabled?: boolean
}
export type McpServerConfig = McpLocalConfig | McpRemoteConfig
export interface LoadedMcpServer {
name: string
scope: McpScope
config: McpServerConfig
}
export interface McpLoadResult {
servers: Record<string, McpServerConfig>
loadedServers: LoadedMcpServer[]
}

View File

@@ -0,0 +1,21 @@
export function detectInterrupt(error: unknown): boolean {
if (!error) return false
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined
const message = errObj.message as string | undefined
if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message?.includes("abort")) return true
const msgLower = message?.toLowerCase()
if (msgLower?.includes("aborted") || msgLower?.includes("cancelled") || msgLower?.includes("interrupted")) return true
}
if (typeof error === "string") {
const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
}
return false
}

View File

@@ -0,0 +1,3 @@
export * from "./types"
export * from "./state"
export * from "./detector"

View File

@@ -0,0 +1,31 @@
import type { SessionErrorState, SessionInterruptState } from "./types"
export const sessionErrorState = new Map<string, SessionErrorState>()
export const sessionInterruptState = new Map<string, SessionInterruptState>()
export const subagentSessions = new Set<string>()
export const sessionFirstMessageProcessed = new Set<string>()
export let currentSessionID: string | undefined
export let currentSessionTitle: string | undefined
export let mainSessionID: string | undefined
export function setCurrentSession(id: string | undefined, title: string | undefined) {
currentSessionID = id
currentSessionTitle = title
}
export function setMainSession(id: string | undefined) {
mainSessionID = id
}
export function getCurrentSessionID(): string | undefined {
return currentSessionID
}
export function getCurrentSessionTitle(): string | undefined {
return currentSessionTitle
}
export function getMainSessionID(): string | undefined {
return mainSessionID
}

View File

@@ -0,0 +1,8 @@
export interface SessionErrorState {
hasError: boolean
errorMessage?: string
}
export interface SessionInterruptState {
interrupted: boolean
}

View File

@@ -0,0 +1,2 @@
export * from "./types"
export * from "./loader"

View File

@@ -0,0 +1,85 @@
import { existsSync, readdirSync, readFileSync, statSync, readlinkSync } from "fs"
import { homedir } from "os"
import { join, resolve } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: LoadedSkillAsCommand[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(skillsDir, entry.name)
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
let resolvedPath = skillPath
if (statSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
}
const skillMdPath = join(resolvedPath, "SKILL.md")
if (!existsSync(skillMdPath)) continue
try {
const content = readFileSync(skillMdPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const skillName = data.name || entry.name
const originalDescription = data.description || ""
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model),
}
skills.push({
name: skillName,
path: resolvedPath,
definition,
scope,
})
} catch {
continue
}
}
return skills
}
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
const userSkillsDir = join(homedir(), ".claude", "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project")
return skills.reduce((acc, skill) => {
acc[skill.name] = skill.definition
return acc
}, {} as Record<string, CommandDefinition>)
}

View File

@@ -0,0 +1,16 @@
import type { CommandDefinition } from "../claude-code-command-loader/types"
export type SkillScope = "user" | "project"
export interface SkillMetadata {
name: string
description: string
model?: string
}
export interface LoadedSkillAsCommand {
name: string
path: string
definition: CommandDefinition
scope: SkillScope
}

View File

@@ -0,0 +1,74 @@
import type { AutoCompactState } from "./types"
type Client = {
session: {
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
summarize: (opts: {
path: { id: string }
body: { providerID: string; modelID: string }
query: { directory: string }
}) => Promise<unknown>
}
tui: {
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
}
}
export async function getLastAssistant(
sessionID: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string
): Promise<Record<string, unknown> | null> {
try {
const resp = await (client as Client).session.messages({
path: { id: sessionID },
query: { directory },
})
const data = (resp as { data?: unknown[] }).data
if (!Array.isArray(data)) return null
const reversed = [...data].reverse()
const last = reversed.find((m) => {
const msg = m as Record<string, unknown>
const info = msg.info as Record<string, unknown> | undefined
return info?.role === "assistant"
})
if (!last) return null
return (last as { info?: Record<string, unknown> }).info ?? null
} catch {
return null
}
}
export async function executeCompact(
sessionID: string,
msg: Record<string, unknown>,
autoCompactState: AutoCompactState,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
client: any,
directory: string
): Promise<void> {
try {
const providerID = msg.providerID as string | undefined
const modelID = msg.modelID as string | undefined
if (providerID && modelID) {
await (client as Client).session.summarize({
path: { id: sessionID },
body: { providerID, modelID },
query: { directory },
})
setTimeout(async () => {
try {
await (client as Client).tui.submitPrompt({ query: { directory } })
} catch {}
}, 500)
}
autoCompactState.pendingCompact.delete(sessionID)
autoCompactState.errorDataBySession.delete(sessionID)
} catch {}
}

View File

@@ -0,0 +1,123 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
}
}
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
const autoCompactState = createAutoCompactState()
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
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) {
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
}
return
}
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const parsed = parseAnthropicTokenLimitError(props?.error)
if (parsed) {
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
}
return
}
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "assistant" && info.error) {
const parsed = parseAnthropicTokenLimitError(info.error)
if (parsed) {
parsed.providerID = info.providerID as string | undefined
parsed.modelID = info.modelID as string | undefined
autoCompactState.pendingCompact.add(sessionID)
autoCompactState.errorDataBySession.set(sessionID, parsed)
}
}
return
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
if (!autoCompactState.pendingCompact.has(sessionID)) return
const errorData = autoCompactState.errorDataBySession.get(sessionID)
if (errorData?.providerID && errorData?.modelID) {
await ctx.client.tui
.showToast({
body: {
title: "Auto Compact",
message: "Token limit exceeded. Summarizing session...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
await executeCompact(
sessionID,
{ providerID: errorData.providerID, modelID: errorData.modelID },
autoCompactState,
ctx.client,
ctx.directory
)
return
}
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
if (!lastAssistant) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
if (lastAssistant.summary === true) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
if (!lastAssistant.modelID || !lastAssistant.providerID) {
autoCompactState.pendingCompact.delete(sessionID)
return
}
await ctx.client.tui
.showToast({
body: {
title: "Auto Compact",
message: "Token limit exceeded. Summarizing session...",
variant: "warning" as const,
duration: 3000,
},
})
.catch(() => {})
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
}
}
return {
event: eventHandler,
}
}
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
export { parseAnthropicTokenLimitError } from "./parser"
export { executeCompact, getLastAssistant } from "./executor"

View File

@@ -0,0 +1,154 @@
import type { ParsedTokenLimitError } from "./types"
interface AnthropicErrorData {
type: "error"
error: {
type: string
message: string
}
request_id?: string
}
const TOKEN_LIMIT_PATTERNS = [
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
/(\d+).*?tokens.*?limit.*?(\d+)/i,
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
]
const TOKEN_LIMIT_KEYWORDS = [
"prompt is too long",
"is too long",
"context_length_exceeded",
"max_tokens",
"token limit",
"context length",
"too many tokens",
]
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
for (const pattern of TOKEN_LIMIT_PATTERNS) {
const match = message.match(pattern)
if (match) {
const num1 = parseInt(match[1], 10)
const num2 = parseInt(match[2], 10)
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
}
}
return null
}
function isTokenLimitError(text: string): boolean {
const lower = text.toLowerCase()
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
}
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
if (typeof err === "string") {
if (isTokenLimitError(err)) {
const tokens = extractTokensFromMessage(err)
return {
currentTokens: tokens?.current ?? 0,
maxTokens: tokens?.max ?? 0,
errorType: "token_limit_exceeded_string",
}
}
return null
}
if (!err || typeof err !== "object") return null
const errObj = err as Record<string, unknown>
const dataObj = errObj.data as Record<string, unknown> | undefined
const responseBody = dataObj?.responseBody
const errorMessage = errObj.message as string | undefined
const errorData = errObj.error as Record<string, unknown> | undefined
const nestedError = errorData?.error as Record<string, unknown> | undefined
const textSources: string[] = []
if (typeof responseBody === "string") textSources.push(responseBody)
if (typeof errorMessage === "string") textSources.push(errorMessage)
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
if (textSources.length === 0) {
try {
const jsonStr = JSON.stringify(errObj)
if (isTokenLimitError(jsonStr)) {
textSources.push(jsonStr)
}
} catch {}
}
const combinedText = textSources.join(" ")
if (!isTokenLimitError(combinedText)) return null
if (typeof responseBody === "string") {
try {
const jsonPatterns = [
/data:\s*(\{[\s\S]*?\})\s*$/m,
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
/(\{[\s\S]*?"error"[\s\S]*?\})/,
]
for (const pattern of jsonPatterns) {
const dataMatch = responseBody.match(pattern)
if (dataMatch) {
try {
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
const message = jsonData.error?.message || ""
const tokens = extractTokensFromMessage(message)
if (tokens) {
return {
currentTokens: tokens.current,
maxTokens: tokens.max,
requestId: jsonData.request_id,
errorType: jsonData.error?.type || "token_limit_exceeded",
}
}
} catch {}
}
}
const bedrockJson = JSON.parse(responseBody)
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
return {
currentTokens: 0,
maxTokens: 0,
errorType: "bedrock_input_too_long",
}
}
} catch {}
}
for (const text of textSources) {
const tokens = extractTokensFromMessage(text)
if (tokens) {
return {
currentTokens: tokens.current,
maxTokens: tokens.max,
errorType: "token_limit_exceeded",
}
}
}
if (isTokenLimitError(combinedText)) {
return {
currentTokens: 0,
maxTokens: 0,
errorType: "token_limit_exceeded_unknown",
}
}
return null
}

View File

@@ -0,0 +1,13 @@
export interface ParsedTokenLimitError {
currentTokens: number
maxTokens: number
requestId?: string
errorType: string
providerID?: string
modelID?: string
}
export interface AutoCompactState {
pendingCompact: Set<string>
errorDataBySession: Map<string, ParsedTokenLimitError>
}

View File

@@ -15,36 +15,13 @@ function debugLog(...args: unknown[]) {
}
}
type Platform = "darwin" | "linux" | "win32" | "unsupported"
function getPlatformPackageName(): string | null {
const platform = process.platform as Platform
const arch = process.arch
const platformMap: Record<string, string> = {
"darwin-arm64": "@code-yeongyu/comment-checker-darwin-arm64",
"darwin-x64": "@code-yeongyu/comment-checker-darwin-x64",
"linux-arm64": "@code-yeongyu/comment-checker-linux-arm64",
"linux-x64": "@code-yeongyu/comment-checker-linux-x64",
"win32-x64": "@code-yeongyu/comment-checker-windows-x64",
}
return platformMap[`${platform}-${arch}`] ?? null
}
function getBinaryName(): string {
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
}
/**
* Synchronously find comment-checker binary path.
* Checks installed packages, homebrew, cache, and system PATH.
* Does NOT trigger download.
*/
function findCommentCheckerPathSync(): string | null {
const binaryName = getBinaryName()
// 1. Try to find from @code-yeongyu/comment-checker package
try {
const require = createRequire(import.meta.url)
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
@@ -59,46 +36,12 @@ function findCommentCheckerPathSync(): string | null {
debugLog("main package not installed")
}
// 2. Try platform-specific package directly (legacy, for backwards compatibility)
const platformPkg = getPlatformPackageName()
if (platformPkg) {
try {
const require = createRequire(import.meta.url)
const pkgPath = require.resolve(`${platformPkg}/package.json`)
const pkgDir = dirname(pkgPath)
const binaryPath = join(pkgDir, "bin", binaryName)
if (existsSync(binaryPath)) {
debugLog("found binary in platform package:", binaryPath)
return binaryPath
}
} catch {
debugLog("platform package not installed:", platformPkg)
}
}
// 3. Try homebrew installation (macOS)
if (process.platform === "darwin") {
const homebrewPaths = [
"/opt/homebrew/bin/comment-checker",
"/usr/local/bin/comment-checker",
]
for (const path of homebrewPaths) {
if (existsSync(path)) {
debugLog("found binary via homebrew:", path)
return path
}
}
}
// 4. Try cached binary (lazy download location)
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
debugLog("found binary in cache:", cachedPath)
return cachedPath
}
// 5. Try system PATH (as fallback)
debugLog("no binary found in known locations")
return null
}

View File

@@ -72,6 +72,10 @@ PRIORITY-BASED ACTION GUIDELINES:
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
is a code smell. If you need separators, your file is too long or poorly organized.
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
Review in the above priority order and take the corresponding action EVERY TIME this appears.

View File

@@ -4,6 +4,7 @@ export { createSessionNotification } from "./session-notification";
export { createSessionRecoveryHook } from "./session-recovery";
export { createCommentCheckerHooks } from "./comment-checker";
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
export { createPulseMonitorHook } from "./pulse-monitor";
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode";

View File

@@ -1,146 +0,0 @@
import type { PluginInput } from "@opencode-ai/plugin"
export function createPulseMonitorHook(ctx: PluginInput) {
const STANDARD_TIMEOUT = 5 * 60 * 1000 // 5 minutes
const THINKING_TIMEOUT = 5 * 60 * 1000 // 5 minutes
const CHECK_INTERVAL = 5 * 1000 // 5 seconds
let lastHeartbeat = Date.now()
let isMonitoring = false
let currentSessionID: string | null = null
let monitorTimer: ReturnType<typeof setInterval> | null = null
let isThinking = false
const startMonitoring = (sessionID: string) => {
if (currentSessionID !== sessionID) {
currentSessionID = sessionID
// Reset thinking state when switching sessions or starting new
isThinking = false
}
lastHeartbeat = Date.now()
if (!isMonitoring) {
isMonitoring = true
if (monitorTimer) clearInterval(monitorTimer)
monitorTimer = setInterval(async () => {
if (!isMonitoring || !currentSessionID) return
const timeSinceLastHeartbeat = Date.now() - lastHeartbeat
const currentTimeout = isThinking ? THINKING_TIMEOUT : STANDARD_TIMEOUT
if (timeSinceLastHeartbeat > currentTimeout) {
await recoverStalledSession(currentSessionID, timeSinceLastHeartbeat, isThinking)
}
}, CHECK_INTERVAL)
}
}
const stopMonitoring = () => {
isMonitoring = false
if (monitorTimer) {
clearInterval(monitorTimer)
monitorTimer = null
}
}
const updateHeartbeat = (isThinkingUpdate?: boolean) => {
if (isMonitoring) {
lastHeartbeat = Date.now()
if (isThinkingUpdate !== undefined) {
isThinking = isThinkingUpdate
}
}
}
const recoverStalledSession = async (sessionID: string, stalledDuration: number, wasThinking: boolean) => {
stopMonitoring()
try {
const durationSec = Math.round(stalledDuration/1000)
const typeStr = wasThinking ? "Thinking" : "Standard"
// 1. Notify User
await ctx.client.tui.showToast({
body: {
title: "Pulse Monitor: Cardiac Arrest",
message: `Session stalled (${typeStr}) for ${durationSec}s. Defibrillating...`,
variant: "error",
duration: 5000
}
}).catch(() => {})
// 2. Abort current generation (Defibrillation shock)
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
// 3. Wait a bit for state to settle
await new Promise(resolve => setTimeout(resolve, 1500))
// 4. Prompt "continue" to kickstart (CPR)
await ctx.client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "The connection was unstable and stalled. Please continue from where you left off." }] },
query: { directory: ctx.directory }
})
// Resume monitoring
startMonitoring(sessionID)
} catch (err) {
console.error("[PulseMonitor] Recovery failed:", err)
// If recovery fails, we stop monitoring to avoid loops
stopMonitoring()
}
}
return {
event: async (input: { event: any }) => {
const { event } = input
const props = event.properties as Record<string, any> | undefined
// Monitor both session updates and part updates to capture token flow
if (event.type === "session.updated" || event.type === "message.part.updated") {
// Try to get sessionID from various common locations
const sessionID = props?.info?.id || props?.sessionID
if (sessionID) {
if (!isMonitoring) startMonitoring(sessionID)
// Check for thinking indicators in the payload
let thinkingUpdate: boolean | undefined = undefined
if (event.type === "message.part.updated") {
const part = props?.part
if (part) {
const THINKING_TYPES = ["thinking", "redacted_thinking", "reasoning"]
if (THINKING_TYPES.includes(part.type)) {
thinkingUpdate = true
} else if (part.type === "text" || part.type === "tool_use") {
thinkingUpdate = false
}
}
}
updateHeartbeat(thinkingUpdate)
}
} else if (event.type === "session.idle" || event.type === "session.error" || event.type === "session.stopped") {
stopMonitoring()
}
},
"tool.execute.before": async () => {
// Pause monitoring while tool runs locally (tools can take time)
stopMonitoring()
},
"tool.execute.after": async (input: { sessionID: string }) => {
// Reset heartbeat after tool execution to prevent false positives
// Tools can take arbitrary time, so we need a fresh baseline
if (input.sessionID && currentSessionID === input.sessionID) {
lastHeartbeat = Date.now()
}
// Don't forcefully restart monitoring here
// Monitoring will naturally resume when next session/message event arrives
// This prevents stalled detection on legitimately idle sessions
}
}
}

View File

@@ -0,0 +1,57 @@
const ENGLISH_PATTERNS = [/\bultrathink\b/i, /\bthink\b/i]
const MULTILINGUAL_KEYWORDS = [
"생각", "고민", "검토", "제대로",
"思考", "考虑", "考慮",
"思考", "考え", "熟考",
"सोच", "विचार",
"تفكير", "تأمل",
"চিন্তা", "ভাবনা",
"думать", "думай", "размышлять", "размышляй",
"pensar", "pense", "refletir", "reflita",
"pensar", "piensa", "reflexionar", "reflexiona",
"penser", "pense", "réfléchir", "réfléchis",
"denken", "denk", "nachdenken",
"suy nghĩ", "cân nhắc",
"düşün", "düşünmek",
"pensare", "pensa", "riflettere", "rifletti",
"คิด", "พิจารณา",
"myśl", "myśleć", "zastanów",
"denken", "denk", "nadenken",
"berpikir", "pikir", "pertimbangkan",
"думати", "думай", "роздумувати",
"σκέψου", "σκέφτομαι",
"myslet", "mysli", "přemýšlet",
"gândește", "gândi", "reflectă",
"tänka", "tänk", "fundera",
"gondolkodj", "gondolkodni",
"ajattele", "ajatella", "pohdi",
"tænk", "tænke", "overvej",
"tenk", "tenke", "gruble",
"חשוב", "לחשוב", "להרהר",
"fikir", "berfikir",
]
const MULTILINGUAL_PATTERNS = MULTILINGUAL_KEYWORDS.map((kw) => new RegExp(kw, "i"))
const THINK_PATTERNS = [...ENGLISH_PATTERNS, ...MULTILINGUAL_PATTERNS]
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
const INLINE_CODE_PATTERN = /`[^`]+`/g
function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
}
export function detectThinkKeyword(text: string): boolean {
const textWithoutCode = removeCodeBlocks(text)
return THINK_PATTERNS.some((pattern) => pattern.test(textWithoutCode))
}
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join("")
}

View File

@@ -0,0 +1,73 @@
import { detectThinkKeyword, extractPromptText } from "./detector"
import { getHighVariant, isAlreadyHighVariant } from "./switcher"
import type { ThinkModeState, ThinkModeInput } from "./types"
export * from "./detector"
export * from "./switcher"
export * from "./types"
const thinkModeState = new Map<string, ThinkModeState>()
export function clearThinkModeState(sessionID: string): void {
thinkModeState.delete(sessionID)
}
export function createThinkModeHook() {
return {
"chat.params": async (
output: ThinkModeInput,
sessionID: string
): Promise<void> => {
const promptText = extractPromptText(output.parts)
const state: ThinkModeState = {
requested: false,
modelSwitched: false,
}
if (!detectThinkKeyword(promptText)) {
thinkModeState.set(sessionID, state)
return
}
state.requested = true
const currentModel = output.message.model
if (!currentModel) {
thinkModeState.set(sessionID, state)
return
}
state.providerID = currentModel.providerID
state.modelID = currentModel.modelID
if (isAlreadyHighVariant(currentModel.modelID)) {
thinkModeState.set(sessionID, state)
return
}
const highVariant = getHighVariant(currentModel.modelID)
if (!highVariant) {
thinkModeState.set(sessionID, state)
return
}
output.message.model = {
providerID: currentModel.providerID,
modelID: highVariant,
}
state.modelSwitched = true
thinkModeState.set(sessionID, state)
},
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
if (event.type === "session.deleted") {
const props = event.properties as { info?: { id?: string } } | undefined
if (props?.info?.id) {
thinkModeState.delete(props.info.id)
}
}
},
}
}

View File

@@ -0,0 +1,91 @@
const HIGH_VARIANT_MAP: Record<string, string> = {
"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-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high",
}
const ALREADY_HIGH: Set<string> = new Set([
"claude-sonnet-4-5-high",
"claude-opus-4-5-high",
"gpt-5.1-high",
"gpt-5.1-codex-high",
"gemini-3-pro-high",
])
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
anthropic: {
thinking: {
type: "enabled",
budgetTokens: 64000,
},
},
"amazon-bedrock": {
reasoningConfig: {
type: "enabled",
budgetTokens: 32000,
},
},
google: {
providerOptions: {
google: {
thinkingConfig: {
thinkingLevel: "HIGH",
},
},
},
},
"google-vertex": {
providerOptions: {
"google-vertex": {
thinkingConfig: {
thinkingLevel: "HIGH",
},
},
},
},
}
const THINKING_CAPABLE_MODELS: Record<string, string[]> = {
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
"amazon-bedrock": ["claude", "anthropic"],
google: ["gemini-2", "gemini-3"],
"google-vertex": ["gemini-2", "gemini-3"],
}
export function getHighVariant(modelID: string): string | null {
if (ALREADY_HIGH.has(modelID)) {
return null
}
return HIGH_VARIANT_MAP[modelID] ?? null
}
export function isAlreadyHighVariant(modelID: string): boolean {
return ALREADY_HIGH.has(modelID) || modelID.endsWith("-high")
}
export function getThinkingConfig(
providerID: string,
modelID: string
): Record<string, unknown> | null {
if (isAlreadyHighVariant(modelID)) {
return null
}
const config = THINKING_CONFIGS[providerID]
const capablePatterns = THINKING_CAPABLE_MODELS[providerID]
if (!config || !capablePatterns) {
return null
}
const modelLower = modelID.toLowerCase()
const isCapable = capablePatterns.some((pattern) =>
modelLower.includes(pattern.toLowerCase())
)
return isCapable ? config : null
}

View File

@@ -0,0 +1,20 @@
export interface ThinkModeState {
requested: boolean
modelSwitched: boolean
providerID?: string
modelID?: string
}
export interface ModelRef {
providerID: string
modelID: string
}
export interface MessageWithModel {
model?: ModelRef
}
export interface ThinkModeInput {
parts: Array<{ type: string; text?: string }>
message: MessageWithModel
}

View File

@@ -6,14 +6,36 @@ import {
createSessionRecoveryHook,
createCommentCheckerHooks,
createGrepOutputTruncatorHook,
createPulseMonitorHook,
createDirectoryAgentsInjectorHook,
createEmptyTaskResponseDetectorHook,
createThinkModeHook,
} from "./hooks";
import {
loadUserCommands,
loadProjectCommands,
loadOpencodeGlobalCommands,
loadOpencodeProjectCommands,
} from "./features/claude-code-command-loader";
import {
loadUserSkillsAsCommands,
loadProjectSkillsAsCommands,
} from "./features/claude-code-skill-loader";
import {
loadUserAgents,
loadProjectAgents,
} from "./features/claude-code-agent-loader";
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
import {
setCurrentSession,
setMainSession,
getMainSessionID,
getCurrentSessionTitle,
} from "./features/claude-code-session-state";
import { updateTerminalTitle } from "./features/terminal";
import { builtinTools } from "./tools";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import { log } from "./shared/logger";
import * as fs from "fs";
import * as path from "path";
@@ -31,12 +53,7 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
console.error(
`[oh-my-opencode] Config validation error in ${configPath}:`,
);
for (const issue of result.error.issues) {
console.error(` - ${issue.path.join(".")}: ${issue.message}`);
}
log(`Config validation error in ${configPath}:`, result.error.issues);
return {};
}
@@ -54,47 +71,68 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx);
const pulseMonitor = createPulseMonitorHook(ctx);
const commentChecker = createCommentCheckerHooks();
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
const thinkMode = createThinkModeHook();
updateTerminalTitle({ sessionId: "main" });
const pluginConfig = loadPluginConfig(ctx.directory);
let mainSessionID: string | undefined;
let currentSessionID: string | undefined;
let currentSessionTitle: string | undefined;
return {
tool: builtinTools,
config: async (config) => {
const agents = createBuiltinAgents(
const builtinAgents = createBuiltinAgents(
pluginConfig.disabled_agents,
pluginConfig.agents,
);
const userAgents = loadUserAgents();
const projectAgents = loadProjectAgents();
config.agent = {
...config.agent,
...agents,
...builtinAgents,
...userAgents,
...projectAgents,
};
config.tools = {
...config.tools,
};
const mcpResult = await loadMcpConfigs();
config.mcp = {
...config.mcp,
...createBuiltinMcps(pluginConfig.disabled_mcps),
...mcpResult.servers,
};
const userCommands = loadUserCommands();
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
const systemCommands = config.command ?? {};
const projectCommands = loadProjectCommands();
const opencodeProjectCommands = loadOpencodeProjectCommands();
const userSkills = loadUserSkillsAsCommands();
const projectSkills = loadProjectSkillsAsCommands();
config.command = {
...userCommands,
...userSkills,
...opencodeGlobalCommands,
...systemCommands,
...projectCommands,
...projectSkills,
...opencodeProjectCommands,
};
},
event: async (input) => {
await todoContinuationEnforcer(input);
await contextWindowMonitor.event(input);
await pulseMonitor.event(input);
await directoryAgentsInjector.event(input);
await thinkMode.event(input);
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
@@ -104,14 +142,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
| { id?: string; title?: string; parentID?: string }
| undefined;
if (!sessionInfo?.parentID) {
mainSessionID = sessionInfo?.id;
currentSessionID = sessionInfo?.id;
currentSessionTitle = sessionInfo?.title;
setMainSession(sessionInfo?.id);
setCurrentSession(sessionInfo?.id, sessionInfo?.title);
updateTerminalTitle({
sessionId: currentSessionID || "main",
sessionId: sessionInfo?.id || "main",
status: "idle",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
sessionTitle: sessionInfo?.title,
});
}
}
@@ -121,23 +158,21 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
| { id?: string; title?: string; parentID?: string }
| undefined;
if (!sessionInfo?.parentID) {
currentSessionID = sessionInfo?.id;
currentSessionTitle = sessionInfo?.title;
setCurrentSession(sessionInfo?.id, sessionInfo?.title);
updateTerminalTitle({
sessionId: currentSessionID || "main",
sessionId: sessionInfo?.id || "main",
status: "processing",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
sessionTitle: sessionInfo?.title,
});
}
}
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined;
if (sessionInfo?.id === mainSessionID) {
mainSessionID = undefined;
currentSessionID = undefined;
currentSessionTitle = undefined;
if (sessionInfo?.id === getMainSessionID()) {
setMainSession(undefined);
setCurrentSession(undefined, undefined);
updateTerminalTitle({
sessionId: "main",
status: "idle",
@@ -159,7 +194,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const recovered =
await sessionRecovery.handleSessionRecovery(messageInfo);
if (recovered && sessionID && sessionID === mainSessionID) {
if (recovered && sessionID && sessionID === getMainSessionID()) {
await ctx.client.session
.prompt({
path: { id: sessionID },
@@ -170,58 +205,56 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}
}
if (sessionID && sessionID === mainSessionID) {
if (sessionID && sessionID === getMainSessionID()) {
updateTerminalTitle({
sessionId: sessionID,
status: "error",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
sessionTitle: getCurrentSessionTitle(),
});
}
}
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined;
if (sessionID && sessionID === mainSessionID) {
if (sessionID && sessionID === getMainSessionID()) {
updateTerminalTitle({
sessionId: sessionID,
status: "idle",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
sessionTitle: getCurrentSessionTitle(),
});
}
}
},
"tool.execute.before": async (input, output) => {
await pulseMonitor["tool.execute.before"]();
await commentChecker["tool.execute.before"](input, output);
if (input.sessionID === mainSessionID) {
if (input.sessionID === getMainSessionID()) {
updateTerminalTitle({
sessionId: input.sessionID,
status: "tool",
currentTool: input.tool,
directory: ctx.directory,
sessionTitle: currentSessionTitle,
sessionTitle: getCurrentSessionTitle(),
});
}
},
"tool.execute.after": async (input, output) => {
await pulseMonitor["tool.execute.after"](input);
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 emptyTaskResponseDetector["tool.execute.after"](input, output);
if (input.sessionID === mainSessionID) {
if (input.sessionID === getMainSessionID()) {
updateTerminalTitle({
sessionId: input.sessionID,
status: "idle",
directory: ctx.directory,
sessionTitle: currentSessionTitle,
sessionTitle: getCurrentSessionTitle(),
});
}
},

View File

@@ -0,0 +1,203 @@
import { spawn } from "child_process"
import { exec } from "child_process"
import { promisify } from "util"
import { existsSync } from "fs"
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
function findZshPath(customZshPath?: string): string | null {
if (customZshPath && existsSync(customZshPath)) {
return customZshPath
}
for (const path of DEFAULT_ZSH_PATHS) {
if (existsSync(path)) {
return path
}
}
return null
}
const execAsync = promisify(exec)
export interface CommandResult {
exitCode: number
stdout?: string
stderr?: string
}
export interface ExecuteHookOptions {
forceZsh?: boolean
zshPath?: string
}
/**
* Execute a hook command with stdin input
*/
export async function executeHookCommand(
command: string,
stdin: string,
cwd: string,
options?: ExecuteHookOptions
): Promise<CommandResult> {
const home = process.env.HOME ?? ""
let expandedCommand = command
.replace(/^~(?=\/|$)/g, home)
.replace(/\s~(?=\/)/g, ` ${home}`)
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
let finalCommand = expandedCommand
if (options?.forceZsh) {
const zshPath = options.zshPath || findZshPath()
if (zshPath) {
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
finalCommand = `${zshPath} -lc '${escapedCommand}'`
}
}
return new Promise((resolve) => {
const proc = spawn(finalCommand, {
cwd,
shell: true,
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
})
let stdout = ""
let stderr = ""
proc.stdout?.on("data", (data) => {
stdout += data.toString()
})
proc.stderr?.on("data", (data) => {
stderr += data.toString()
})
proc.stdin?.write(stdin)
proc.stdin?.end()
proc.on("close", (code) => {
resolve({
exitCode: code ?? 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
})
})
proc.on("error", (err) => {
resolve({
exitCode: 1,
stderr: err.message,
})
})
})
}
/**
* Execute a simple command and return output
*/
export async function executeCommand(command: string): Promise<string> {
try {
const { stdout, stderr } = await execAsync(command)
const out = stdout?.toString().trim() ?? ""
const err = stderr?.toString().trim() ?? ""
if (err) {
if (out) {
return `${out}\n[stderr: ${err}]`
}
return `[stderr: ${err}]`
}
return out
} catch (error: unknown) {
const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string }
const stdout = e?.stdout?.toString().trim() ?? ""
const stderr = e?.stderr?.toString().trim() ?? ""
const errMsg = stderr || e?.message || String(error)
if (stdout) {
return `${stdout}\n[stderr: ${errMsg}]`
}
return `[stderr: ${errMsg}]`
}
}
/**
* Find and execute embedded commands in text (!`command`)
*/
interface CommandMatch {
fullMatch: string
command: string
start: number
end: number
}
const COMMAND_PATTERN = /!`([^`]+)`/g
function findCommands(text: string): CommandMatch[] {
const matches: CommandMatch[] = []
let match: RegExpExecArray | null
COMMAND_PATTERN.lastIndex = 0
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
matches.push({
fullMatch: match[0],
command: match[1],
start: match.index,
end: match.index + match[0].length,
})
}
return matches
}
/**
* Resolve embedded commands in text recursively
*/
export async function resolveCommandsInText(
text: string,
depth: number = 0,
maxDepth: number = 3
): Promise<string> {
if (depth >= maxDepth) {
return text
}
const matches = findCommands(text)
if (matches.length === 0) {
return text
}
const tasks = matches.map((m) => executeCommand(m.command))
const results = await Promise.allSettled(tasks)
const replacements = new Map<string, string>()
matches.forEach((match, idx) => {
const result = results[idx]
if (result.status === "rejected") {
replacements.set(
match.fullMatch,
`[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]`
)
} else {
replacements.set(match.fullMatch, result.value)
}
})
let resolved = text
for (const [pattern, replacement] of replacements.entries()) {
resolved = resolved.split(pattern).join(replacement)
}
if (findCommands(resolved).length > 0) {
return resolveCommandsInText(resolved, depth + 1, maxDepth)
}
return resolved
}

View File

@@ -0,0 +1,85 @@
import { existsSync, readFileSync, statSync } from "fs"
import { join, isAbsolute } from "path"
interface FileMatch {
fullMatch: string
filePath: string
start: number
end: number
}
const FILE_REFERENCE_PATTERN = /@([^\s@]+)/g
function findFileReferences(text: string): FileMatch[] {
const matches: FileMatch[] = []
let match: RegExpExecArray | null
FILE_REFERENCE_PATTERN.lastIndex = 0
while ((match = FILE_REFERENCE_PATTERN.exec(text)) !== null) {
matches.push({
fullMatch: match[0],
filePath: match[1],
start: match.index,
end: match.index + match[0].length,
})
}
return matches
}
function resolveFilePath(filePath: string, cwd: string): string {
if (isAbsolute(filePath)) {
return filePath
}
return join(cwd, filePath)
}
function readFileContent(resolvedPath: string): string {
if (!existsSync(resolvedPath)) {
return `[file not found: ${resolvedPath}]`
}
const stat = statSync(resolvedPath)
if (stat.isDirectory()) {
return `[cannot read directory: ${resolvedPath}]`
}
const content = readFileSync(resolvedPath, "utf-8")
return content
}
export async function resolveFileReferencesInText(
text: string,
cwd: string = process.cwd(),
depth: number = 0,
maxDepth: number = 3
): Promise<string> {
if (depth >= maxDepth) {
return text
}
const matches = findFileReferences(text)
if (matches.length === 0) {
return text
}
const replacements = new Map<string, string>()
for (const match of matches) {
const resolvedPath = resolveFilePath(match.filePath, cwd)
const content = readFileContent(resolvedPath)
replacements.set(match.fullMatch, content)
}
let resolved = text
for (const [pattern, replacement] of replacements.entries()) {
resolved = resolved.split(pattern).join(replacement)
}
if (findFileReferences(resolved).length > 0 && depth + 1 < maxDepth) {
return resolveFileReferencesInText(resolved, cwd, depth + 1, maxDepth)
}
return resolved
}

34
src/shared/frontmatter.ts Normal file
View File

@@ -0,0 +1,34 @@
export interface FrontmatterResult<T = Record<string, string>> {
data: T
body: string
}
export function parseFrontmatter<T = Record<string, string>>(
content: string
): FrontmatterResult<T> {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
const match = content.match(frontmatterRegex)
if (!match) {
return { data: {} as T, body: content }
}
const yamlContent = match[1]
const body = match[2]
const data: Record<string, string | boolean> = {}
for (const line of yamlContent.split("\n")) {
const colonIndex = line.indexOf(":")
if (colonIndex !== -1) {
const key = line.slice(0, colonIndex).trim()
let value: string | boolean = line.slice(colonIndex + 1).trim()
if (value === "true") value = true
else if (value === "false") value = false
data[key] = value
}
}
return { data: data as T, body }
}

5
src/shared/index.ts Normal file
View File

@@ -0,0 +1,5 @@
export * from "./frontmatter"
export * from "./command-executor"
export * from "./file-reference-resolver"
export * from "./model-sanitizer"
export * from "./logger"

20
src/shared/logger.ts Normal file
View File

@@ -0,0 +1,20 @@
// Shared logging utility for the plugin
import * as fs from "fs"
import * as os from "os"
import * as path from "path"
const logFile = path.join(os.tmpdir(), "oh-my-opencode.log")
export function log(message: string, data?: unknown): void {
try {
const timestamp = new Date().toISOString()
const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
fs.appendFileSync(logFile, logEntry)
} catch {
}
}
export function getLogFilePath(): string {
return logFile
}

View File

@@ -0,0 +1,13 @@
/**
* Sanitizes model field from frontmatter.
* Always returns undefined to let SDK use default model.
*
* Claude Code and OpenCode use different model ID formats,
* so we ignore the model field and let OpenCode use its configured default.
*
* @param _model - Raw model value from frontmatter (ignored)
* @returns Always undefined to inherit default model
*/
export function sanitizeModelField(_model: unknown): undefined {
return undefined
}

View File

@@ -1,9 +1,8 @@
import { tool } from "@opencode-ai/plugin/tool"
import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants"
import { CLI_LANGUAGES } from "./constants"
import { runSg } from "./cli"
import { analyzeCode, transformCode, getRootInfo } from "./napi"
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils"
import type { CliLanguage, NapiLanguage } from "./types"
import { formatSearchResult, formatReplaceResult } from "./utils"
import type { CliLanguage } from "./types"
function showOutputToUser(context: unknown, output: string): void {
const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
@@ -110,83 +109,4 @@ export const ast_grep_replace = tool({
},
})
export const ast_grep_languages = tool({
description:
"List all supported languages for ast-grep tools with their file extensions. " +
"Use this to determine valid language options.",
args: {},
execute: async (_args, context) => {
const lines: string[] = [`Supported Languages (${CLI_LANGUAGES.length}):`]
for (const lang of CLI_LANGUAGES) {
const exts = LANG_EXTENSIONS[lang]?.join(", ") || ""
lines.push(` ${lang}: ${exts}`)
}
lines.push("")
lines.push(`NAPI (in-memory) languages: ${NAPI_LANGUAGES.join(", ")}`)
const output = lines.join("\n")
showOutputToUser(context, output)
return output
},
})
export const ast_grep_analyze = tool({
description:
"Parse code and extract AST structure with pattern matching (in-memory). " +
"Extracts meta-variable bindings. Only for: html, javascript, tsx, css, typescript. " +
"Use for detailed code analysis without file I/O.",
args: {
code: tool.schema.string().describe("Source code to analyze"),
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language (html, javascript, tsx, css, typescript)"),
pattern: tool.schema.string().optional().describe("Pattern to find (omit for root structure)"),
extractMetaVars: tool.schema.boolean().optional().describe("Extract meta-variable bindings (default: true)"),
},
execute: async (args, context) => {
try {
if (!args.pattern) {
const info = getRootInfo(args.code, args.lang as NapiLanguage)
const output = `Root kind: ${info.kind}\nChildren: ${info.childCount}`
showOutputToUser(context, output)
return output
}
const results = analyzeCode(args.code, args.lang as NapiLanguage, args.pattern, args.extractMetaVars !== false)
const output = formatAnalyzeResult(results, args.extractMetaVars !== false)
showOutputToUser(context, output)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
showOutputToUser(context, output)
return output
}
},
})
export const ast_grep_transform = tool({
description:
"Transform code in-memory using AST-aware rewriting. " +
"Only for: html, javascript, tsx, css, typescript. " +
"Returns transformed code without writing to filesystem.",
args: {
code: tool.schema.string().describe("Source code to transform"),
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language"),
pattern: tool.schema.string().describe("Pattern to match"),
rewrite: tool.schema.string().describe("Replacement (can use $VAR from pattern)"),
},
execute: async (args, context) => {
try {
const { transformed, editCount } = transformCode(
args.code,
args.lang as NapiLanguage,
args.pattern,
args.rewrite
)
const output = formatTransformResult(args.code, transformed, editCount)
showOutputToUser(context, output)
return output
} catch (e) {
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
showOutputToUser(context, output)
return output
}
},
})

View File

@@ -19,6 +19,8 @@ import {
import { grep } from "./grep"
import { glob } from "./glob"
import { slashcommand } from "./slashcommand"
import { skill } from "./skill"
export const builtinTools = {
lsp_hover,
@@ -36,4 +38,6 @@ export const builtinTools = {
ast_grep_replace,
grep,
glob,
slashcommand,
skill,
}

2
src/tools/skill/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./types"
export { skill } from "./tools"

326
src/tools/skill/tools.ts Normal file
View File

@@ -0,0 +1,326 @@
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 { parseFrontmatter, resolveCommandsInText } from "../../shared"
import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill } from "./types"
function discoverSkillsFromDir(
skillsDir: string,
scope: SkillScope
): Array<{ name: string; description: string; scope: SkillScope }> {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: Array<{ name: string; description: string; scope: SkillScope }> = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
let resolvedPath = skillPath
try {
const stats = statSync(skillPath, { throwIfNoEntry: false })
if (stats?.isSymbolicLink()) {
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
}
} catch {
continue
}
const skillMdPath = join(resolvedPath, "SKILL.md")
if (!existsSync(skillMdPath)) continue
try {
const content = readFileSync(skillMdPath, "utf-8")
const { data } = parseFrontmatter(content)
skills.push({
name: data.name || entry.name,
description: data.description || "",
scope,
})
} catch {
continue
}
}
}
return skills
}
function discoverSkillsSync(): Array<{ name: string; description: string; scope: SkillScope }> {
const userSkillsDir = join(homedir(), ".claude", "skills")
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const userSkills = discoverSkillsFromDir(userSkillsDir, "user")
const projectSkills = discoverSkillsFromDir(projectSkillsDir, "project")
return [...projectSkills, ...userSkills]
}
const availableSkills = discoverSkillsSync()
const skillListForDescription = availableSkills
.map((s) => `- ${s.name}: ${s.description} (${s.scope})`)
.join("\n")
function resolveSymlink(skillPath: string): string {
try {
const stats = statSync(skillPath, { throwIfNoEntry: false })
if (stats?.isSymbolicLink()) {
return resolve(skillPath, "..", readlinkSync(skillPath))
}
return skillPath
} catch {
return skillPath
}
}
async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
const resolvedPath = resolveSymlink(skillPath)
const skillMdPath = join(resolvedPath, "SKILL.md")
if (!existsSync(skillMdPath)) {
return null
}
try {
let content = readFileSync(skillMdPath, "utf-8")
content = await resolveCommandsInText(content)
const { data, body } = parseFrontmatter(content)
const metadata: SkillMetadata = {
name: data.name || basename(skillPath),
description: data.description || "",
license: data.license,
}
const referencesDir = join(resolvedPath, "references")
const scriptsDir = join(resolvedPath, "scripts")
const assetsDir = join(resolvedPath, "assets")
const references = existsSync(referencesDir)
? readdirSync(referencesDir).filter((f) => !f.startsWith("."))
: []
const scripts = existsSync(scriptsDir)
? readdirSync(scriptsDir).filter((f) => !f.startsWith(".") && !f.startsWith("__"))
: []
const assets = existsSync(assetsDir)
? readdirSync(assetsDir).filter((f) => !f.startsWith("."))
: []
return {
name: metadata.name,
path: resolvedPath,
metadata,
content: body,
references,
scripts,
assets,
}
} catch {
return null
}
}
async function discoverSkillsFromDirAsync(skillsDir: string): Promise<SkillInfo[]> {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: SkillInfo[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const skillPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
const skillInfo = await parseSkillMd(skillPath)
if (skillInfo) {
skills.push(skillInfo)
}
}
}
return skills
}
async function discoverSkills(): Promise<SkillInfo[]> {
const userSkillsDir = join(homedir(), ".claude", "skills")
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const userSkills = await discoverSkillsFromDirAsync(userSkillsDir)
const projectSkills = await discoverSkillsFromDirAsync(projectSkillsDir)
return [...projectSkills, ...userSkills]
}
function findMatchingSkills(skills: SkillInfo[], query: string): SkillInfo[] {
const queryLower = query.toLowerCase()
const queryTerms = queryLower.split(/\s+/).filter(Boolean)
return skills
.map((skill) => {
let score = 0
const nameLower = skill.metadata.name.toLowerCase()
const descLower = skill.metadata.description.toLowerCase()
if (nameLower === queryLower) score += 100
if (nameLower.includes(queryLower)) score += 50
for (const term of queryTerms) {
if (nameLower.includes(term)) score += 20
if (descLower.includes(term)) score += 10
}
return { skill, score }
})
.filter(({ score }) => score > 0)
.sort((a, b) => b.score - a.score)
.map(({ skill }) => skill)
}
async function loadSkillWithReferences(
skill: SkillInfo,
includeRefs: boolean
): Promise<LoadedSkill> {
const referencesLoaded: Array<{ path: string; content: string }> = []
if (includeRefs && skill.references.length > 0) {
for (const ref of skill.references) {
const refPath = join(skill.path, "references", ref)
try {
let content = readFileSync(refPath, "utf-8")
content = await resolveCommandsInText(content)
referencesLoaded.push({ path: ref, content })
} catch {
}
}
}
return {
name: skill.name,
metadata: skill.metadata,
body: skill.content,
referencesLoaded,
}
}
function formatSkillList(skills: SkillInfo[]): string {
if (skills.length === 0) {
return "No skills found in ~/.claude/skills/"
}
const lines = ["# Available Skills\n"]
for (const skill of skills) {
lines.push(`- **${skill.metadata.name}**: ${skill.metadata.description || "(no description)"}`)
}
lines.push(`\n**Total**: ${skills.length} skills`)
return lines.join("\n")
}
function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
if (loadedSkills.length === 0) {
return "No skills loaded."
}
const sections: string[] = ["# Loaded Skills\n"]
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())
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")
}
}
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.")
return sections.join("\n")
}
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.)
Available Skills:
${skillListForDescription}`,
args: {
skill: tool.schema
.string()
.describe(
"The skill name or search query to find and load. Can be exact skill name (e.g., 'python-programmer') or keywords (e.g., 'python', 'plan')."
),
},
async execute(args) {
const skills = await discoverSkills()
if (!args.skill) {
return formatSkillList(skills) + "\n\nProvide a skill name to load."
}
const matchingSkills = findMatchingSkills(skills, args.skill)
if (matchingSkills.length === 0) {
return (
`No skills found matching "${args.skill}".\n\n` +
formatSkillList(skills) +
"\n\nTry a different skill name."
)
}
const loadedSkills: LoadedSkill[] = []
for (const skillInfo of matchingSkills.slice(0, 3)) {
const loaded = await loadSkillWithReferences(skillInfo, true)
loadedSkills.push(loaded)
}
return formatLoadedSkills(loadedSkills)
},
})

24
src/tools/skill/types.ts Normal file
View File

@@ -0,0 +1,24 @@
export type SkillScope = "user" | "project"
export interface SkillMetadata {
name: string
description: string
license?: string
}
export interface SkillInfo {
name: string
path: string
metadata: SkillMetadata
content: string
references: string[]
scripts: string[]
assets: string[]
}
export interface LoadedSkill {
name: string
metadata: SkillMetadata
body: string
referencesLoaded: Array<{ path: string; content: string }>
}

View File

@@ -0,0 +1,2 @@
export * from "./types"
export { slashcommand } from "./tools"

View File

@@ -0,0 +1,202 @@
import { tool } from "@opencode-ai/plugin"
import { existsSync, readdirSync, readFileSync } from "fs"
import { homedir } from "os"
import { join, basename, dirname } from "path"
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
import type { CommandScope, CommandMetadata, CommandInfo } from "./types"
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
if (!existsSync(commandsDir)) {
return []
}
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: CommandInfo[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
if (!entry.name.endsWith(".md")) continue
if (!entry.isFile()) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
try {
const content = readFileSync(commandPath, "utf-8")
const { data, body } = parseFrontmatter(content)
const metadata: CommandMetadata = {
name: commandName,
description: data.description || "",
argumentHint: data["argument-hint"],
model: sanitizeModelField(data.model),
agent: data.agent,
subtask: Boolean(data.subtask),
}
commands.push({
name: commandName,
path: commandPath,
metadata,
content: body,
scope,
})
} catch {
continue
}
}
return commands
}
function discoverCommandsSync(): CommandInfo[] {
const userCommandsDir = join(homedir(), ".claude", "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands]
}
const availableCommands = discoverCommandsSync()
const commandListForDescription = availableCommands
.map((cmd) => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
})
.join("\n")
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
const sections: string[] = []
sections.push(`# /${cmd.name} Command\n`)
if (cmd.metadata.description) {
sections.push(`**Description**: ${cmd.metadata.description}\n`)
}
if (cmd.metadata.argumentHint) {
sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`)
}
if (cmd.metadata.model) {
sections.push(`**Model**: ${cmd.metadata.model}\n`)
}
if (cmd.metadata.agent) {
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
}
if (cmd.metadata.subtask) {
sections.push(`**Subtask**: true\n`)
}
sections.push(`**Scope**: ${cmd.scope}\n`)
sections.push("---\n")
sections.push("## Command Instructions\n")
const commandDir = dirname(cmd.path)
const withFileRefs = await resolveFileReferencesInText(cmd.content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
return sections.join("\n")
}
function formatCommandList(commands: CommandInfo[]): string {
if (commands.length === 0) {
return "No commands found."
}
const lines = ["# Available Commands\n"]
for (const cmd of commands) {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
lines.push(
`- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})`
)
}
lines.push(`\n**Total**: ${commands.length} commands`)
return lines.join("\n")
}
export const slashcommand = tool({
description: `Execute a slash command within the main conversation.
When you use this tool, the slash command gets expanded to a full prompt that provides detailed instructions on how to complete the task.
How slash commands work:
- Invoke commands using this tool with the command name (without arguments)
- The command's prompt will expand and provide detailed instructions
- Arguments from user input should be passed separately
Important:
- Only use commands listed in Available Commands below
- Do not invoke a command that is already running
- **CRITICAL**: When user's message starts with '/' (e.g., "/commit", "/plan"), you MUST immediately invoke this tool with that command. Do NOT attempt to handle the command manually.
Commands are loaded from (priority order, highest wins):
- .opencode/command/ (opencode-project - OpenCode project-specific commands)
- ./.claude/commands/ (project - Claude Code project-specific commands)
- ~/.config/opencode/command/ (opencode - OpenCode global commands)
- ~/.claude/commands/ (user - Claude Code global commands)
Each command is a markdown file with:
- YAML frontmatter: description, argument-hint, model, agent, subtask (optional)
- Markdown body: The command instructions/prompt
- File references: @path/to/file (relative to command file location)
- Shell injection: \`!\`command\`\` (executes and injects output)
Available Commands:
${commandListForDescription}`,
args: {
command: tool.schema
.string()
.describe(
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
),
},
async execute(args) {
const commands = discoverCommandsSync()
if (!args.command) {
return formatCommandList(commands) + "\n\nProvide a command name to execute."
}
const cmdName = args.command.replace(/^\//, "")
const exactMatch = commands.find(
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
)
if (exactMatch) {
return await formatLoadedCommand(exactMatch)
}
const partialMatches = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
)
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(commands)
)
}
return (
`Command "/${cmdName}" not found.\n\n` +
formatCommandList(commands) +
"\n\nTry a different command name."
)
},
})

View File

@@ -0,0 +1,18 @@
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
export interface CommandMetadata {
name: string
description: string
argumentHint?: string
model?: string
agent?: string
subtask?: boolean
}
export interface CommandInfo {
name: string
path: string
metadata: CommandMetadata
content: string
scope: CommandScope
}