Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0193a7e45 | ||
|
|
bedbc621fa | ||
|
|
e147be7ed4 | ||
|
|
124c3b3e8f | ||
|
|
5678e0bac6 | ||
|
|
207435450c | ||
|
|
376bf363af | ||
|
|
c7a65af475 | ||
|
|
8e7447deee | ||
|
|
15a748b817 | ||
|
|
c0e0dc1f95 | ||
|
|
7059407cbc | ||
|
|
589cf60252 | ||
|
|
e5cdaa5192 | ||
|
|
340eb30147 | ||
|
|
e72b927ccb | ||
|
|
3c6ffe5d9c | ||
|
|
938a3709e1 | ||
|
|
47f218e33f | ||
|
|
e07a25baa4 | ||
|
|
08ede0a28d | ||
|
|
a711d58289 | ||
|
|
431ec14991 | ||
|
|
62cae8114d | ||
|
|
e6eafe267a | ||
|
|
e4ef832405 | ||
|
|
ef6d67645e | ||
|
|
227d93f106 |
25
README.ko.md
25
README.ko.md
@@ -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/`
|
||||
|
||||
## 설정
|
||||
|
||||
|
||||
24
README.md
24
README.md
@@ -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
|
||||
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
337
notepad.md
337
notepad.md
@@ -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분
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
2
src/features/claude-code-agent-loader/index.ts
Normal file
2
src/features/claude-code-agent-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
93
src/features/claude-code-agent-loader/loader.ts
Normal file
93
src/features/claude-code-agent-loader/loader.ts
Normal 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
|
||||
}
|
||||
17
src/features/claude-code-agent-loader/types.ts
Normal file
17
src/features/claude-code-agent-loader/types.ts
Normal 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
|
||||
}
|
||||
2
src/features/claude-code-command-loader/index.ts
Normal file
2
src/features/claude-code-command-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
94
src/features/claude-code-command-loader/loader.ts
Normal file
94
src/features/claude-code-command-loader/loader.ts
Normal 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)
|
||||
}
|
||||
26
src/features/claude-code-command-loader/types.ts
Normal file
26
src/features/claude-code-command-loader/types.ts
Normal 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
|
||||
}
|
||||
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal 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
|
||||
}
|
||||
11
src/features/claude-code-mcp-loader/index.ts
Normal file
11
src/features/claude-code-mcp-loader/index.ts
Normal 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"
|
||||
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
89
src/features/claude-code-mcp-loader/loader.ts
Normal 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(", ")
|
||||
}
|
||||
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
53
src/features/claude-code-mcp-loader/transformer.ts
Normal 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
|
||||
}
|
||||
42
src/features/claude-code-mcp-loader/types.ts
Normal file
42
src/features/claude-code-mcp-loader/types.ts
Normal 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[]
|
||||
}
|
||||
21
src/features/claude-code-session-state/detector.ts
Normal file
21
src/features/claude-code-session-state/detector.ts
Normal 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
|
||||
}
|
||||
3
src/features/claude-code-session-state/index.ts
Normal file
3
src/features/claude-code-session-state/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./state"
|
||||
export * from "./detector"
|
||||
31
src/features/claude-code-session-state/state.ts
Normal file
31
src/features/claude-code-session-state/state.ts
Normal 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
|
||||
}
|
||||
8
src/features/claude-code-session-state/types.ts
Normal file
8
src/features/claude-code-session-state/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SessionErrorState {
|
||||
hasError: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface SessionInterruptState {
|
||||
interrupted: boolean
|
||||
}
|
||||
2
src/features/claude-code-skill-loader/index.ts
Normal file
2
src/features/claude-code-skill-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
85
src/features/claude-code-skill-loader/loader.ts
Normal file
85
src/features/claude-code-skill-loader/loader.ts
Normal 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>)
|
||||
}
|
||||
16
src/features/claude-code-skill-loader/types.ts
Normal file
16
src/features/claude-code-skill-loader/types.ts
Normal 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
|
||||
}
|
||||
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
74
src/hooks/anthropic-auto-compact/executor.ts
Normal 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 {}
|
||||
}
|
||||
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
123
src/hooks/anthropic-auto-compact/index.ts
Normal 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"
|
||||
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
154
src/hooks/anthropic-auto-compact/parser.ts
Normal 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
|
||||
}
|
||||
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
13
src/hooks/anthropic-auto-compact/types.ts
Normal 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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/hooks/think-mode/detector.ts
Normal file
57
src/hooks/think-mode/detector.ts
Normal 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("")
|
||||
}
|
||||
73
src/hooks/think-mode/index.ts
Normal file
73
src/hooks/think-mode/index.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
91
src/hooks/think-mode/switcher.ts
Normal file
91
src/hooks/think-mode/switcher.ts
Normal 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
|
||||
}
|
||||
20
src/hooks/think-mode/types.ts
Normal file
20
src/hooks/think-mode/types.ts
Normal 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
|
||||
}
|
||||
111
src/index.ts
111
src/index.ts
@@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
203
src/shared/command-executor.ts
Normal file
203
src/shared/command-executor.ts
Normal 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
|
||||
}
|
||||
85
src/shared/file-reference-resolver.ts
Normal file
85
src/shared/file-reference-resolver.ts
Normal 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
34
src/shared/frontmatter.ts
Normal 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
5
src/shared/index.ts
Normal 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
20
src/shared/logger.ts
Normal 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
|
||||
}
|
||||
13
src/shared/model-sanitizer.ts
Normal file
13
src/shared/model-sanitizer.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
2
src/tools/skill/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { skill } from "./tools"
|
||||
326
src/tools/skill/tools.ts
Normal file
326
src/tools/skill/tools.ts
Normal 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
24
src/tools/skill/types.ts
Normal 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 }>
|
||||
}
|
||||
2
src/tools/slashcommand/index.ts
Normal file
2
src/tools/slashcommand/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { slashcommand } from "./tools"
|
||||
202
src/tools/slashcommand/tools.ts
Normal file
202
src/tools/slashcommand/tools.ts
Normal 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."
|
||||
)
|
||||
},
|
||||
})
|
||||
18
src/tools/slashcommand/types.ts
Normal file
18
src/tools/slashcommand/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user