Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d570af3dd | ||
|
|
ddeabb1a8b | ||
|
|
7a896fd2b9 | ||
|
|
823f12d88d | ||
|
|
bf3dd91da2 | ||
|
|
fd957e7ed0 | ||
|
|
3ba61790ab | ||
|
|
3224c15578 | ||
|
|
a51ad98182 | ||
|
|
b98a1b28f8 | ||
|
|
9a92dc8d95 | ||
|
|
99711dacc1 | ||
|
|
6eaa96f421 | ||
|
|
f6b066ecfa | ||
|
|
4434a59cf0 | ||
|
|
038d838e63 | ||
|
|
dc057e9910 | ||
|
|
d4787c477a | ||
|
|
e6ffdc4352 | ||
|
|
a1fe0f8517 | ||
|
|
bebe6607d4 | ||
|
|
f088f008cc | ||
|
|
f64210c505 | ||
|
|
b75383fb99 | ||
|
|
70fe08a15f | ||
|
|
13ebeb9853 | ||
|
|
2452a4789d | ||
|
|
44640b985d | ||
|
|
794b5263c2 | ||
|
|
b0c39e222a | ||
|
|
bd05f5b434 | ||
|
|
a82575b55f | ||
|
|
ff760e5865 | ||
|
|
4039722160 | ||
|
|
439785ef90 | ||
|
|
e5330311dd | ||
|
|
b122273c2f | ||
|
|
06dee7248b | ||
|
|
c8aed3f428 | ||
|
|
1d4b5dec4a | ||
|
|
a217610ae4 | ||
|
|
b3775719b4 | ||
|
|
490c0b626f | ||
|
|
b30c17ac77 | ||
|
|
a5983f1678 | ||
|
|
2948d94a3c | ||
|
|
c66cfbb8c6 | ||
|
|
f66c886e0d | ||
|
|
1c55385cb5 | ||
|
|
f3db564b2e | ||
|
|
15b0ee80e1 | ||
|
|
2cab836a3b | ||
|
|
4efa58616f | ||
|
|
fbae3aeb6b | ||
|
|
74da07d584 | ||
|
|
7cd04a246c | ||
|
|
1de7df4933 | ||
|
|
ea6121ee1c | ||
|
|
4939f81625 | ||
|
|
820b339fae | ||
|
|
5412578600 |
BIN
.github/assets/orchestrator-sisyphus.png
vendored
Normal file
BIN
.github/assets/orchestrator-sisyphus.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 984 KiB |
@@ -1,7 +1,6 @@
|
||||
---
|
||||
description: Publish oh-my-opencode to npm via GitHub Actions workflow
|
||||
argument-hint: <patch|minor|major>
|
||||
model: opencode/big-pickle
|
||||
---
|
||||
|
||||
<command-instruction>
|
||||
|
||||
115
AGENTS.md
115
AGENTS.md
@@ -1,29 +1,29 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2025-12-28T19:26:00+09:00
|
||||
**Commit:** 122e918
|
||||
**Generated:** 2026-01-02T10:35:00+09:00
|
||||
**Commit:** bebe660
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orchestration (GPT-5.2, Claude, Gemini, Grok), LSP tools (11), AST-Grep search, MCP integrations (context7, websearch_exa, grep_app). "oh-my-zsh" for OpenCode.
|
||||
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok), 11 LSP tools, AST-Grep, Claude Code compatibility layer. "oh-my-zsh" for OpenCode.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
|
||||
│ ├── hooks/ # 21 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
|
||||
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
|
||||
│ ├── features/ # Claude Code compatibility - see src/features/AGENTS.md
|
||||
│ ├── agents/ # 7 AI agents - see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 22 lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, session mgmt - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Claude Code compat layer - see src/features/AGENTS.md
|
||||
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
|
||||
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ ├── auth/ # Google Antigravity OAuth (antigravity/)
|
||||
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc.
|
||||
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
|
||||
│ └── index.ts # Main plugin entry (723 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
|
||||
@@ -31,58 +31,44 @@ oh-my-opencode/
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
|
||||
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
|
||||
| Add agent | `src/agents/` | Create .ts, add to builtinAgents, update types.ts |
|
||||
| Add hook | `src/hooks/` | Dir with createXXXHook(), export from index.ts |
|
||||
| Add tool | `src/tools/` | Dir with constants/types/tools.ts, add to builtinTools |
|
||||
| Add MCP | `src/mcp/` | Create config, add to index.ts |
|
||||
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
|
||||
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
|
||||
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| Add skill | `src/features/builtin-skills/` | Dir with SKILL.md |
|
||||
| Config schema | `src/config/schema.ts` | Run `bun run build:schema` after |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts for task management |
|
||||
| Interactive terminal | `src/tools/interactive-bash/` | tmux session management |
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
|
||||
- **Bun only**: `bun run`, `bun test`, `bunx` (NEVER npm/npx)
|
||||
- **Types**: bun-types (not @types/node)
|
||||
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
|
||||
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
|
||||
- **Tool structure**: index.ts, types.ts, constants.ts, tools.ts, utils.ts
|
||||
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
|
||||
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA)
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
|
||||
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
|
||||
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA)
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **npm/yarn**: Use bun exclusively
|
||||
- **@types/node**: Use bun-types
|
||||
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
|
||||
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
|
||||
- **Local version bump**: Version managed by CI workflow
|
||||
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
|
||||
- **Rush completion**: Never mark tasks complete without verification
|
||||
- **Over-exploration**: Stop searching when sufficient context found
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
|
||||
- **Optional props**: Extensive `?` for optional interface properties
|
||||
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
|
||||
- **Error handling**: Consistent try/catch with async/await
|
||||
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
|
||||
- **Temperature**: Most agents use `0.1` for consistency
|
||||
- **Hook naming**: `createXXXHook` function convention
|
||||
| Category | Forbidden |
|
||||
|----------|-----------|
|
||||
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
|
||||
| Package Manager | npm, yarn, npx |
|
||||
| File Ops | Bash mkdir/touch/rm for code file creation |
|
||||
| Publishing | Direct `bun publish`, local version bump |
|
||||
| Agent Behavior | High temp (>0.3), broad tool access, sequential agent calls |
|
||||
| Hooks | Heavy PreToolUse logic, blocking without reason |
|
||||
| Year | 2024 in code/prompts (use current year) |
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Model | Purpose |
|
||||
|-------|-------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
|
||||
| oracle | openai/gpt-5.2 | Strategic advisor, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
|
||||
| explore | opencode/grok-code | Fast codebase exploration |
|
||||
| oracle | openai/gpt-5.2 | Strategy, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Docs, OSS research |
|
||||
| explore | opencode/grok-code | Fast codebase grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical docs |
|
||||
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
|
||||
@@ -93,8 +79,7 @@ oh-my-opencode/
|
||||
bun run typecheck # Type check
|
||||
bun run build # ESM + declarations + schema
|
||||
bun run rebuild # Clean + Build
|
||||
bun run build:schema # Schema only
|
||||
bun test # Run tests
|
||||
bun test # Run tests (380+)
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
@@ -102,20 +87,26 @@ bun test # Run tests
|
||||
**GitHub Actions workflow_dispatch only**
|
||||
|
||||
1. Never modify package.json version locally
|
||||
2. Commit & push changes
|
||||
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
|
||||
2. Commit & push to dev
|
||||
3. Trigger: `gh workflow run publish -f bump=patch|minor|major`
|
||||
|
||||
**Critical**: Never `bun publish` directly. Never bump version locally.
|
||||
CI auto-commits schema changes on master, maintains rolling `next` draft release on dev.
|
||||
|
||||
## CI PIPELINE
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/index.ts` | 723 | Main plugin, all hook/tool init |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt |
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`
|
||||
- **OpenCode**: Requires >= 1.0.150
|
||||
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
|
||||
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
|
||||
- **Config**: `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`
|
||||
- **JSONC**: Config files support comments and trailing commas
|
||||
- **Claude Code**: Full compat layer for settings.json hooks, commands, skills, agents, MCPs
|
||||
- **Skill MCP**: Skills can embed MCP server configs in YAML frontmatter
|
||||
|
||||
24
README.ja.md
24
README.ja.md
@@ -128,6 +128,12 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||
- なぜこれが良いのか、単なるボイラープレートではないのか、具体的に何が良くなるのか聞いてみてください。
|
||||
- もしあなたが LLM エージェントなら、このプロジェクトによって今すぐ何が可能になるのか、ユーザーに説明してください。
|
||||
|
||||
### 🪄 魔法の言葉:`ultrawork`
|
||||
|
||||
**これ全部読みたくない?プロンプトに `ultrawork`(または `ulw`)を入れるだけでOK。**
|
||||
|
||||
それだけです。私たちが提供するすべての機能が魔法のように動作します—並列エージェント、バックグラウンドタスク、深い探索、そして完了するまで止まらない実行。エージェントが自動的にすべてを処理します。
|
||||
|
||||
### 読みたい方のために:シジフォスに会う
|
||||
|
||||

|
||||
@@ -223,8 +229,12 @@ OpenCode がインストールされていない場合は、[OpenCode インス
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
# bunx が動作しない場合は npx を使用
|
||||
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
```
|
||||
|
||||
> **Ubuntu/Debian ユーザーへの注意**: Snap で Bun をインストールした場合 (`/snap/bin/bun`)、Snap のサンドボックス化により `bunx` が「script not found」エラーで失敗します。代わりに `npx` を使用するか、公式インストーラーで Bun を再インストールしてください: `curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
**例:**
|
||||
- すべてのサブスクリプション + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
|
||||
- Claude のみ(max20 なし): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
|
||||
@@ -759,7 +769,19 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
各エージェントでサポートされるオプション:`model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`。
|
||||
各エージェントでサポートされるオプション:`model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`。
|
||||
|
||||
`prompt_append` を使用すると、デフォルトのシステムプロンプトを置き換えずに追加の指示を付け加えられます:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "Emacs Lisp のドキュメント検索には常に elisp-dev-mcp を使用してください。"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Sisyphus` (メインオーケストレーター) と `build` (デフォルトエージェント) も同じオプションで設定をオーバーライドできます。
|
||||
|
||||
|
||||
24
README.ko.md
24
README.ko.md
@@ -127,6 +127,12 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
- 이게 왜 좋고, 그냥 뻔한 셋업이 아닌지, 당장 무엇이 더 좋아지는지 알려달라고 해보세요.
|
||||
- 당신이 LLM 에이전트라면, 사용자의 입장에서 이 프로젝트와 함께 당장 어떤것들이 가능해지는지 사용자에게 설명하세요.
|
||||
|
||||
### 🪄 마법의 단어: `ultrawork`
|
||||
|
||||
**이거 다 읽기 싫으시죠? 프롬프트에 `ultrawork` (또는 `ulw`) 만 넣으세요.**
|
||||
|
||||
그게 다입니다. 우리가 제공하는 모든 기능이 마법처럼 작동합니다—병렬 에이전트, 백그라운드 태스크, 깊은 탐색, 그리고 완료될 때까지 멈추지 않는 실행. 에이전트가 알아서 다 합니다.
|
||||
|
||||
### 하지만 읽고 싶은 당신을 위해: 시지푸스를 만나보세요
|
||||
|
||||

|
||||
@@ -220,8 +226,12 @@ OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://o
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
# bunx가 작동하지 않으면 npx 사용
|
||||
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
```
|
||||
|
||||
> **Ubuntu/Debian 사용자 참고**: Snap으로 Bun을 설치한 경우 (`/snap/bin/bun`), Snap의 샌드박싱으로 인해 `bunx`가 "script not found" 오류와 함께 실패합니다. 대신 `npx`를 사용하거나, 공식 설치 스크립트로 Bun을 재설치하세요: `curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
**예시:**
|
||||
- 모든 구독 + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
|
||||
- Claude만 (max20 없음): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
|
||||
@@ -752,7 +762,19 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
`prompt_append`를 사용하면 기본 시스템 프롬프트를 대체하지 않고 추가 지시사항을 덧붙일 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "Emacs Lisp 문서 조회 시 항상 elisp-dev-mcp를 사용하세요."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Sisyphus` (메인 오케스트레이터)와 `build` (기본 에이전트)도 동일한 옵션으로 설정을 오버라이드할 수 있습니다.
|
||||
|
||||
|
||||
63
README.md
63
README.md
@@ -2,6 +2,9 @@
|
||||
>
|
||||
> *"I aim to spark a software revolution by creating a world where agent-generated code is indistinguishable from human code, yet capable of achieving vastly more. I have poured my personal time, passion, and funds into this journey, and I will continue to do so."*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **The Orchestrator is coming. This Week. [Get notified on X](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
@@ -149,6 +152,12 @@ Hand this doc to an agent and let them set it up.
|
||||
- Ask why it's good, why it's not just another boilerplate setup, what actually gets better.
|
||||
- If you're an LLM agent, explain to the user what becomes possible right now with this project.
|
||||
|
||||
### 🪄 The Magic Word: `ultrawork`
|
||||
|
||||
**Don't want to read all this? Just include `ultrawork` (or `ulw`) in your prompt.**
|
||||
|
||||
That's it. All the features we provide will work like magic—parallel agents, background tasks, deep exploration, and relentless execution until completion. The agent figures out the rest automatically.
|
||||
|
||||
### For Those Who Want to Read: Meet Sisyphus
|
||||
|
||||

|
||||
@@ -201,8 +210,12 @@ Run the interactive installer:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# or use npx if bunx doesn't work
|
||||
npx oh-my-opencode install
|
||||
```
|
||||
|
||||
> **Note for Ubuntu/Debian users**: If you installed Bun via Snap (`/snap/bin/bun`), `bunx` will fail with "script not found" due to Snap's sandboxing. Either use `npx` instead, or reinstall Bun via the official installer: `curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
|
||||
|
||||
**Alternative: Let an LLM Agent do it**
|
||||
@@ -574,6 +587,26 @@ Instead of the agent reading massive files and bloating context, it internally l
|
||||
#### I Removed Their Blockers
|
||||
- Replaces built-in grep and glob tools. Default implementation has no timeout—can hang forever.
|
||||
|
||||
#### Skill-Embedded MCP Support
|
||||
|
||||
Skills can now bring their own MCP servers. Define MCP configurations directly in skill frontmatter or via `mcp.json` files:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: Browser automation skill
|
||||
mcp:
|
||||
playwright:
|
||||
command: npx
|
||||
args: ["-y", "@anthropic-ai/mcp-playwright"]
|
||||
---
|
||||
```
|
||||
|
||||
When you load a skill with embedded MCP, its tools become available automatically. The `skill_mcp` tool lets you invoke these MCP operations with full schema discovery.
|
||||
|
||||
**Built-in Skills:**
|
||||
- **playwright**: Browser automation, web scraping, testing, and screenshots out of the box
|
||||
|
||||
Disable built-in skills via `disabled_skills: ["playwright"]` in your config.
|
||||
|
||||
### Goodbye Claude Code. Hello Oh My OpenCode.
|
||||
|
||||
@@ -791,7 +824,19 @@ Override built-in agent settings:
|
||||
}
|
||||
```
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
Use `prompt_append` to add extra instructions without replacing the default system prompt:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "Always use the elisp-dev-mcp for Emacs Lisp documentation lookups."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
You can also override settings for `Sisyphus` (the main orchestrator) and `build` (the default agent) using the same options.
|
||||
|
||||
@@ -831,6 +876,22 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`, `multimodal-looker`
|
||||
|
||||
### Built-in Skills
|
||||
|
||||
Oh My OpenCode includes built-in skills that provide additional capabilities:
|
||||
|
||||
- **playwright**: Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.
|
||||
|
||||
Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_skills": ["playwright"]
|
||||
}
|
||||
```
|
||||
|
||||
Available built-in skills: `playwright`
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
@@ -130,6 +130,12 @@ oMoMoMoMoMo···
|
||||
- 问问它这玩意儿牛在哪,为什么不仅仅是个样板配置,到底能带来什么质变。
|
||||
- 如果你是 LLM Agent,请告诉你的用户:装上这个,现在立马能做到什么。
|
||||
|
||||
### 🪄 魔法口令:`ultrawork`
|
||||
|
||||
**懒得看这么多?在提示词里加上 `ultrawork`(或 `ulw`)就行了。**
|
||||
|
||||
就这么简单。我们提供的所有功能都会像魔法一样自动生效——并行 Agent、后台任务、深度探索、干到完才收工。Agent 会自动搞定一切。
|
||||
|
||||
### 如果你真的想读读看:认识西西弗斯
|
||||
|
||||

|
||||
@@ -231,8 +237,12 @@ fi
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
# 如果 bunx 不好使就换 npx
|
||||
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
|
||||
```
|
||||
|
||||
> **Ubuntu/Debian 用户注意**:如果你是用 Snap 装的 Bun (`/snap/bin/bun`),由于 Snap 的沙箱机制,`bunx` 会报 "script not found" 错误。要么改用 `npx`,要么用官方脚本重装 Bun:`curl -fsSL https://bun.sh/install | bash`
|
||||
|
||||
**例子:**
|
||||
- 全套订阅 + max20:`bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
|
||||
- 只有 Claude(没 max20):`bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
|
||||
@@ -763,7 +773,19 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
每个 Agent 能改这些:`model`、`temperature`、`top_p`、`prompt`、`tools`、`disable`、`description`、`mode`、`color`、`permission`。
|
||||
每个 Agent 能改这些:`model`、`temperature`、`top_p`、`prompt`、`prompt_append`、`tools`、`disable`、`description`、`mode`、`color`、`permission`。
|
||||
|
||||
用 `prompt_append` 可以在默认系统提示后面追加额外指令,不用替换整个提示:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"librarian": {
|
||||
"prompt_append": "查 Emacs Lisp 文档时用 elisp-dev-mcp。"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Sisyphus`(主编排器)和 `build`(默认 Agent)也能改。
|
||||
|
||||
|
||||
@@ -34,6 +34,15 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_skills": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"playwright"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_hooks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -64,7 +73,8 @@
|
||||
"ralph-loop",
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks"
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
187
bun.lock
187
bun.lock
@@ -9,11 +9,13 @@
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
@@ -21,6 +23,7 @@
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3",
|
||||
@@ -75,6 +78,10 @@
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
|
||||
|
||||
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="],
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.162", "", { "dependencies": { "@opencode-ai/sdk": "1.0.162", "zod": "4.1.8" } }, "sha512-tiJw7SCfSlG/3tY2O0J2UT06OLuazOzsv1zYlFbLxLy/EVedtW0pzxYalO20a4e//vInvOXFkhd2jLyB5vNEVA=="],
|
||||
@@ -93,40 +100,218 @@
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
|
||||
|
||||
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
|
||||
|
||||
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
|
||||
|
||||
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
|
||||
|
||||
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
|
||||
|
||||
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
|
||||
|
||||
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
|
||||
|
||||
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
|
||||
|
||||
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
|
||||
|
||||
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
|
||||
|
||||
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
|
||||
|
||||
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
|
||||
|
||||
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
|
||||
|
||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
|
||||
|
||||
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
|
||||
|
||||
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
|
||||
|
||||
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
|
||||
|
||||
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
|
||||
|
||||
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
|
||||
|
||||
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
|
||||
|
||||
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
|
||||
|
||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||
|
||||
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
|
||||
|
||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||
|
||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
|
||||
|
||||
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
|
||||
|
||||
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.9.0",
|
||||
"version": "2.12.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -54,11 +54,13 @@
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"picocolors": "^1.1.1",
|
||||
"picomatch": "^4.0.2",
|
||||
@@ -66,6 +68,7 @@
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/picomatch": "^3.0.2",
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
@@ -103,6 +103,46 @@
|
||||
"created_at": "2025-12-30T12:04:59Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 349
|
||||
},
|
||||
{
|
||||
"name": "junhoyeo",
|
||||
"id": 32605822,
|
||||
"comment_id": 3701585491,
|
||||
"created_at": "2025-12-31T07:00:36Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 375
|
||||
},
|
||||
{
|
||||
"name": "gtg7784",
|
||||
"id": 32065632,
|
||||
"comment_id": 3701688739,
|
||||
"created_at": "2025-12-31T08:05:25Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 377
|
||||
},
|
||||
{
|
||||
"name": "ul8",
|
||||
"id": 589744,
|
||||
"comment_id": 3701705644,
|
||||
"created_at": "2025-12-31T08:16:46Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 378
|
||||
},
|
||||
{
|
||||
"name": "eudresfs",
|
||||
"id": 66638312,
|
||||
"comment_id": 3702622517,
|
||||
"created_at": "2025-12-31T18:03:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 385
|
||||
},
|
||||
{
|
||||
"name": "vsumner",
|
||||
"id": 308886,
|
||||
"comment_id": 3702872360,
|
||||
"created_at": "2025-12-31T20:40:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 388
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -2,19 +2,20 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
|
||||
7 AI agents for multi-model orchestration. Sisyphus orchestrates, specialists handle domains.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
agents/
|
||||
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Flash)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── sisyphus.ts # Primary orchestrator (504 lines)
|
||||
├── oracle.ts # Strategic advisor
|
||||
├── librarian.ts # Multi-repo research
|
||||
├── explore.ts # Fast codebase grep
|
||||
├── frontend-ui-ux-engineer.ts # UI generation
|
||||
├── document-writer.ts # Technical docs
|
||||
├── multimodal-looker.ts # PDF/image analysis
|
||||
├── sisyphus-prompt-builder.ts # Sisyphus prompt construction
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── types.ts # AgentModelConfig interface
|
||||
@@ -24,66 +25,40 @@ agents/
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Default Model | Fallback | Purpose |
|
||||
|-------|---------------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
|
||||
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
|
||||
| Agent | Model | Fallback | Purpose |
|
||||
|-------|-------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Architecture, debugging, review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, GitHub research |
|
||||
| explore | opencode/grok-code | gemini-3-flash, haiku-4-5 | Contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | Beautiful UI code |
|
||||
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
|
||||
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
|
||||
| multimodal-looker | google/gemini-3-flash | - | Visual analysis |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create `src/agents/my-agent.ts`:
|
||||
```typescript
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const myAgent: AgentConfig = {
|
||||
model: "provider/model-name",
|
||||
temperature: 0.1,
|
||||
system: "Agent system prompt...",
|
||||
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
|
||||
system: "...",
|
||||
tools: { include: ["tool1"] },
|
||||
}
|
||||
```
|
||||
2. Add to `builtinAgents` in `src/agents/index.ts`
|
||||
3. Update `types.ts` if adding new config options
|
||||
2. Add to `builtinAgents` in index.ts
|
||||
3. Update types.ts if new config options
|
||||
|
||||
## AGENT CONFIG OPTIONS
|
||||
## MODEL FALLBACK
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| model | string | Model identifier (provider/model-name) |
|
||||
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
|
||||
| system | string | System prompt (can be multiline template literal) |
|
||||
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
|
||||
| top_p | number | Optional nucleus sampling |
|
||||
| maxTokens | number | Optional max output tokens |
|
||||
`createBuiltinAgents()` handles fallback:
|
||||
1. User config override
|
||||
2. Installer settings (claude max20, gemini antigravity)
|
||||
3. Default model
|
||||
|
||||
## MODEL FALLBACK LOGIC
|
||||
## ANTI-PATTERNS
|
||||
|
||||
`createBuiltinAgents()` in utils.ts handles model fallback:
|
||||
|
||||
1. Check user config override (`agents.{name}.model`)
|
||||
2. Check installer settings (claude max20, gemini antigravity)
|
||||
3. Use default model
|
||||
|
||||
**Fallback order for explore**:
|
||||
- If gemini antigravity enabled → `google/gemini-3-flash`
|
||||
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
|
||||
- Default → `opencode/grok-code` (free)
|
||||
|
||||
## ANTI-PATTERNS (AGENTS)
|
||||
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
|
||||
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
|
||||
|
||||
## SHARED PROMPTS
|
||||
|
||||
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
|
||||
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
|
||||
|
||||
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.
|
||||
- High temperature (>0.3) for code agents
|
||||
- Broad tool access (prefer explicit `include`)
|
||||
- Monolithic prompts (delegate to specialists)
|
||||
- Missing fallbacks for rate-limited models
|
||||
|
||||
@@ -108,18 +108,8 @@ Use the right tool for the job:
|
||||
- **Text patterns** (strings, comments, logs): grep
|
||||
- **File patterns** (find by name/extension): glob
|
||||
- **History/evolution** (when added, who changed): git commands
|
||||
- **External examples** (how others implement): grep_app
|
||||
|
||||
### grep_app Strategy
|
||||
|
||||
grep_app searches millions of public GitHub repos instantly — use it for external patterns and examples.
|
||||
|
||||
**Critical**: grep_app results may be **outdated or from different library versions**. Always:
|
||||
1. Start with grep_app for broad discovery
|
||||
2. Launch multiple grep_app calls with query variations in parallel
|
||||
3. **Cross-validate with local tools** (grep, ast_grep_search, LSP) before trusting results
|
||||
|
||||
Flood with parallel calls. Trust only cross-validated results.`,
|
||||
Flood with parallel calls. Cross-validate findings across multiple tools.`,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@ export interface AvailableTool {
|
||||
category: "lsp" | "ast" | "search" | "session" | "command" | "other"
|
||||
}
|
||||
|
||||
export interface AvailableSkill {
|
||||
name: string
|
||||
description: string
|
||||
location: "user" | "project" | "plugin"
|
||||
}
|
||||
|
||||
export function categorizeTools(toolNames: string[]): AvailableTool[] {
|
||||
return toolNames.map((name) => {
|
||||
let category: AvailableTool["category"] = "other"
|
||||
@@ -51,27 +57,73 @@ function formatToolsForPrompt(tools: AvailableTool[]): string {
|
||||
return parts.join(", ")
|
||||
}
|
||||
|
||||
export function buildKeyTriggersSection(agents: AvailableAgent[]): string {
|
||||
export function buildKeyTriggersSection(agents: AvailableAgent[], skills: AvailableSkill[] = []): string {
|
||||
const keyTriggers = agents
|
||||
.filter((a) => a.metadata.keyTrigger)
|
||||
.map((a) => `- ${a.metadata.keyTrigger}`)
|
||||
|
||||
if (keyTriggers.length === 0) return ""
|
||||
const skillTriggers = skills
|
||||
.filter((s) => s.description)
|
||||
.map((s) => `- **Skill \`${s.name}\`**: ${extractTriggerFromDescription(s.description)}`)
|
||||
|
||||
const allTriggers = [...keyTriggers, ...skillTriggers]
|
||||
|
||||
if (allTriggers.length === 0) return ""
|
||||
|
||||
return `### Key Triggers (check BEFORE classification):
|
||||
${keyTriggers.join("\n")}
|
||||
|
||||
**BLOCKING: Check skills FIRST before any action.**
|
||||
If a skill matches, invoke it IMMEDIATELY via \`skill\` tool.
|
||||
|
||||
${allTriggers.join("\n")}
|
||||
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
|
||||
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.`
|
||||
}
|
||||
|
||||
export function buildToolSelectionTable(agents: AvailableAgent[], tools: AvailableTool[] = []): string {
|
||||
function extractTriggerFromDescription(description: string): string {
|
||||
const triggerMatch = description.match(/Trigger[s]?[:\s]+([^.]+)/i)
|
||||
if (triggerMatch) return triggerMatch[1].trim()
|
||||
|
||||
const activateMatch = description.match(/Activate when[:\s]+([^.]+)/i)
|
||||
if (activateMatch) return activateMatch[1].trim()
|
||||
|
||||
const useWhenMatch = description.match(/Use (?:this )?when[:\s]+([^.]+)/i)
|
||||
if (useWhenMatch) return useWhenMatch[1].trim()
|
||||
|
||||
return description.split(".")[0] || description
|
||||
}
|
||||
|
||||
export function buildToolSelectionTable(
|
||||
agents: AvailableAgent[],
|
||||
tools: AvailableTool[] = [],
|
||||
skills: AvailableSkill[] = []
|
||||
): string {
|
||||
const rows: string[] = [
|
||||
"### Tool Selection:",
|
||||
"### Tool & Skill Selection:",
|
||||
"",
|
||||
"**Priority Order**: Skills → Direct Tools → Agents",
|
||||
"",
|
||||
"| Tool | Cost | When to Use |",
|
||||
"|------|------|-------------|",
|
||||
]
|
||||
|
||||
// Skills section (highest priority)
|
||||
if (skills.length > 0) {
|
||||
rows.push("#### Skills (INVOKE FIRST if matching)")
|
||||
rows.push("")
|
||||
rows.push("| Skill | When to Use |")
|
||||
rows.push("|-------|-------------|")
|
||||
for (const skill of skills) {
|
||||
const shortDesc = extractTriggerFromDescription(skill.description)
|
||||
rows.push(`| \`${skill.name}\` | ${shortDesc} |`)
|
||||
}
|
||||
rows.push("")
|
||||
}
|
||||
|
||||
// Tools and Agents table
|
||||
rows.push("#### Tools & Agents")
|
||||
rows.push("")
|
||||
rows.push("| Resource | Cost | When to Use |")
|
||||
rows.push("|----------|------|-------------|")
|
||||
|
||||
if (tools.length > 0) {
|
||||
const toolsDisplay = formatToolsForPrompt(tools)
|
||||
rows.push(`| ${toolsDisplay} | FREE | Not Complex, Scope Clear, No Implicit Assumptions |`)
|
||||
@@ -88,7 +140,7 @@ export function buildToolSelectionTable(agents: AvailableAgent[], tools: Availab
|
||||
}
|
||||
|
||||
rows.push("")
|
||||
rows.push("**Default flow**: explore/librarian (background) + tools → oracle (if required)")
|
||||
rows.push("**Default flow**: skill (if match) → explore/librarian (background) + tools → oracle (if required)")
|
||||
|
||||
return rows.join("\n")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { isGptModel } from "./types"
|
||||
import type { AvailableAgent, AvailableTool } from "./sisyphus-prompt-builder"
|
||||
import type { AvailableAgent, AvailableTool, AvailableSkill } from "./sisyphus-prompt-builder"
|
||||
import {
|
||||
buildKeyTriggersSection,
|
||||
buildToolSelectionTable,
|
||||
@@ -36,10 +36,25 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
|
||||
</Role>`
|
||||
|
||||
const SISYPHUS_PHASE0_STEP1_3 = `### Step 1: Classify Request Type
|
||||
const SISYPHUS_PHASE0_STEP1_3 = `### Step 0: Check Skills FIRST (BLOCKING)
|
||||
|
||||
**Before ANY classification or action, scan for matching skills.**
|
||||
|
||||
\`\`\`
|
||||
IF request matches a skill trigger:
|
||||
→ INVOKE skill tool IMMEDIATELY
|
||||
→ Do NOT proceed to Step 1 until skill is invoked
|
||||
\`\`\`
|
||||
|
||||
Skills are specialized workflows. When relevant, they handle the task better than manual orchestration.
|
||||
|
||||
---
|
||||
|
||||
### Step 1: Classify Request Type
|
||||
|
||||
| Type | Signal | Action |
|
||||
|------|--------|--------|
|
||||
| **Skill Match** | Matches skill trigger phrase | **INVOKE skill FIRST** via \`skill\` tool |
|
||||
| **Trivial** | Single file, known location, direct answer | Direct tools only (UNLESS Key Trigger applies) |
|
||||
| **Explicit** | Specific file/line, clear command | Execute directly |
|
||||
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
|
||||
@@ -375,9 +390,13 @@ const SISYPHUS_SOFT_GUIDELINES = `## Soft Guidelines
|
||||
|
||||
`
|
||||
|
||||
function buildDynamicSisyphusPrompt(availableAgents: AvailableAgent[], availableTools: AvailableTool[] = []): string {
|
||||
const keyTriggers = buildKeyTriggersSection(availableAgents)
|
||||
const toolSelection = buildToolSelectionTable(availableAgents, availableTools)
|
||||
function buildDynamicSisyphusPrompt(
|
||||
availableAgents: AvailableAgent[],
|
||||
availableTools: AvailableTool[] = [],
|
||||
availableSkills: AvailableSkill[] = []
|
||||
): string {
|
||||
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
|
||||
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
|
||||
const exploreSection = buildExploreSection(availableAgents)
|
||||
const librarianSection = buildLibrarianSection(availableAgents)
|
||||
const frontendSection = buildFrontendSection(availableAgents)
|
||||
@@ -456,12 +475,14 @@ function buildDynamicSisyphusPrompt(availableAgents: AvailableAgent[], available
|
||||
export function createSisyphusAgent(
|
||||
model: string = DEFAULT_MODEL,
|
||||
availableAgents?: AvailableAgent[],
|
||||
availableToolNames?: string[]
|
||||
availableToolNames?: string[],
|
||||
availableSkills?: AvailableSkill[]
|
||||
): AgentConfig {
|
||||
const tools = availableToolNames ? categorizeTools(availableToolNames) : []
|
||||
const skills = availableSkills ?? []
|
||||
const prompt = availableAgents
|
||||
? buildDynamicSisyphusPrompt(availableAgents, tools)
|
||||
: buildDynamicSisyphusPrompt([], tools)
|
||||
? buildDynamicSisyphusPrompt(availableAgents, tools, skills)
|
||||
: buildDynamicSisyphusPrompt([], tools, skills)
|
||||
|
||||
const base = {
|
||||
description:
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory } from "./types"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
import { createOracleAgent } from "./oracle"
|
||||
import { createLibrarianAgent } from "./librarian"
|
||||
import { createExploreAgent } from "./explore"
|
||||
import { createFrontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
|
||||
import { createDocumentWriterAgent } from "./document-writer"
|
||||
import { createMultimodalLookerAgent } from "./multimodal-looker"
|
||||
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
|
||||
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
|
||||
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
|
||||
import { createFrontendUiUxEngineerAgent, FRONTEND_PROMPT_METADATA } from "./frontend-ui-ux-engineer"
|
||||
import { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer"
|
||||
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
|
||||
import type { AvailableAgent } from "./sisyphus-prompt-builder"
|
||||
import { deepMerge } from "../shared"
|
||||
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
@@ -21,6 +22,19 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
|
||||
* (Delegation Table, Tool Selection, Key Triggers, etc.)
|
||||
*/
|
||||
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
|
||||
oracle: ORACLE_PROMPT_METADATA,
|
||||
librarian: LIBRARIAN_PROMPT_METADATA,
|
||||
explore: EXPLORE_PROMPT_METADATA,
|
||||
"frontend-ui-ux-engineer": FRONTEND_PROMPT_METADATA,
|
||||
"document-writer": DOCUMENT_WRITER_PROMPT_METADATA,
|
||||
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
|
||||
}
|
||||
|
||||
function isFactory(source: AgentSource): source is AgentFactory {
|
||||
return typeof source === "function"
|
||||
}
|
||||
@@ -29,18 +43,17 @@ function buildAgent(source: AgentSource, model?: string): AgentConfig {
|
||||
return isFactory(source) ? source(model) : source
|
||||
}
|
||||
|
||||
export function createEnvContext(directory: string): string {
|
||||
/**
|
||||
* Creates OmO-specific environment context (time, timezone, locale).
|
||||
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
|
||||
* so we only include fields that OpenCode doesn't provide to avoid duplication.
|
||||
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
*/
|
||||
export function createEnvContext(): string {
|
||||
const now = new Date()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||
|
||||
const dateStr = now.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
const timeStr = now.toLocaleTimeString("en-US", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@@ -48,18 +61,12 @@ export function createEnvContext(directory: string): string {
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
const platform = process.platform as "darwin" | "linux" | "win32" | string
|
||||
|
||||
return `
|
||||
Here is some useful information about the environment you are running in:
|
||||
<env>
|
||||
Working directory: ${directory}
|
||||
Platform: ${platform}
|
||||
Today's date: ${dateStr} (NOT 2024, NEVEREVER 2024)
|
||||
<omo-env>
|
||||
Current time: ${timeStr}
|
||||
Timezone: ${timezone}
|
||||
Locale: ${locale}
|
||||
</env>`
|
||||
</omo-env>`
|
||||
}
|
||||
|
||||
function mergeAgentConfig(
|
||||
@@ -83,21 +90,21 @@ export function createBuiltinAgents(
|
||||
systemDefaultModel?: string
|
||||
): Record<string, AgentConfig> {
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
const availableAgents: AvailableAgent[] = []
|
||||
|
||||
for (const [name, source] of Object.entries(agentSources)) {
|
||||
const agentName = name as BuiltinAgentName
|
||||
|
||||
if (disabledAgents.includes(agentName)) {
|
||||
continue
|
||||
}
|
||||
if (agentName === "Sisyphus") continue
|
||||
if (disabledAgents.includes(agentName)) continue
|
||||
|
||||
const override = agentOverrides[agentName]
|
||||
const model = override?.model ?? (agentName === "Sisyphus" ? systemDefaultModel : undefined)
|
||||
const model = override?.model
|
||||
|
||||
let config = buildAgent(source, model)
|
||||
|
||||
if ((agentName === "Sisyphus" || agentName === "librarian") && directory && config.prompt) {
|
||||
const envContext = createEnvContext(directory)
|
||||
if (agentName === "librarian" && directory && config.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
config = { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
|
||||
@@ -106,6 +113,33 @@ export function createBuiltinAgents(
|
||||
}
|
||||
|
||||
result[name] = config
|
||||
|
||||
const metadata = agentMetadata[agentName]
|
||||
if (metadata) {
|
||||
availableAgents.push({
|
||||
name: agentName,
|
||||
description: config.description ?? "",
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("Sisyphus")) {
|
||||
const sisyphusOverride = agentOverrides["Sisyphus"]
|
||||
const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel
|
||||
|
||||
let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents)
|
||||
|
||||
if (directory && sisyphusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
|
||||
}
|
||||
|
||||
if (sisyphusOverride) {
|
||||
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
|
||||
}
|
||||
|
||||
result["Sisyphus"] = sisyphusConfig
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
57
src/auth/AGENTS.md
Normal file
57
src/auth/AGENTS.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# AUTH KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Google Antigravity OAuth for Gemini models. Token management, fetch interception, thinking block extraction.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
auth/
|
||||
└── antigravity/
|
||||
├── plugin.ts # Main export, hooks registration
|
||||
├── oauth.ts # OAuth flow, token acquisition
|
||||
├── token.ts # Token storage, refresh logic
|
||||
├── fetch.ts # Fetch interceptor (621 lines)
|
||||
├── response.ts # Response transformation (598 lines)
|
||||
├── thinking.ts # Thinking block extraction (571 lines)
|
||||
├── thought-signature-store.ts # Signature caching
|
||||
├── message-converter.ts # Format conversion
|
||||
├── request.ts # Request building
|
||||
├── project.ts # Project ID management
|
||||
├── tools.ts # OAuth tool registration
|
||||
├── constants.ts # API endpoints, model mappings
|
||||
└── types.ts
|
||||
```
|
||||
|
||||
## KEY COMPONENTS
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| fetch.ts | URL rewriting, token injection, retries |
|
||||
| thinking.ts | Extract `<antThinking>` blocks |
|
||||
| response.ts | Streaming SSE parsing |
|
||||
| oauth.ts | Browser-based OAuth flow |
|
||||
| token.ts | Token persistence, expiry |
|
||||
|
||||
## HOW IT WORKS
|
||||
|
||||
1. **Intercept**: fetch.ts intercepts Anthropic/Google requests
|
||||
2. **Rewrite**: URLs → Antigravity proxy endpoints
|
||||
3. **Auth**: Bearer token from stored OAuth credentials
|
||||
4. **Response**: Streaming parsed, thinking blocks extracted
|
||||
5. **Transform**: Normalized for OpenCode
|
||||
|
||||
## FEATURES
|
||||
|
||||
- Multi-account (up to 10 Google accounts)
|
||||
- Auto-fallback on rate limit
|
||||
- Thinking blocks preserved
|
||||
- Antigravity proxy for AI Studio access
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Direct API calls (use fetch interceptor)
|
||||
- Tokens in code (use token.ts storage)
|
||||
- Ignoring refresh (check expiry first)
|
||||
- Blocking on OAuth (always async)
|
||||
68
src/cli/AGENTS.md
Normal file
68
src/cli/AGENTS.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# CLI KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runtime launcher. Entry: `bunx oh-my-opencode`.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry, subcommand routing
|
||||
├── install.ts # Interactive TUI installer (477 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (669 lines)
|
||||
├── types.ts # CLI-specific types
|
||||
├── doctor/ # Health check system
|
||||
│ ├── index.ts # Doctor command entry
|
||||
│ ├── constants.ts # Check categories
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 17+ individual checks
|
||||
├── get-local-version/ # Version detection
|
||||
└── run/ # OpenCode session launcher
|
||||
```
|
||||
|
||||
## CLI COMMANDS
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `install` | Interactive setup wizard |
|
||||
| `doctor` | Environment health checks |
|
||||
| `run` | Launch OpenCode session |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
|
||||
17+ checks in `doctor/checks/`:
|
||||
- version.ts (OpenCode >= 1.0.150)
|
||||
- config.ts (plugin registered)
|
||||
- bun.ts, node.ts, git.ts
|
||||
- anthropic-auth.ts, openai-auth.ts, google-auth.ts
|
||||
- lsp-*.ts, mcp-*.ts
|
||||
|
||||
## CONFIG-MANAGER (669 lines)
|
||||
|
||||
- JSONC support (comments, trailing commas)
|
||||
- Multi-source: User (~/.config/opencode/) + Project (.opencode/)
|
||||
- Zod validation
|
||||
- Legacy format migration
|
||||
- Error aggregation for doctor
|
||||
|
||||
## HOW TO ADD CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/my-check.ts`:
|
||||
```typescript
|
||||
export const myCheck: DoctorCheck = {
|
||||
name: "my-check",
|
||||
category: "environment",
|
||||
check: async () => {
|
||||
return { status: "pass" | "warn" | "fail", message: "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Add to `src/cli/doctor/checks/index.ts`
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Blocking prompts in non-TTY (check `process.stdout.isTTY`)
|
||||
- Hardcoded paths (use shared utilities)
|
||||
- JSON.parse for user files (use parseJsonc)
|
||||
- Silent failures in doctor checks
|
||||
106
src/cli/doctor/checks/gh.test.ts
Normal file
106
src/cli/doctor/checks/gh.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as gh from "./gh"
|
||||
|
||||
describe("gh cli check", () => {
|
||||
describe("getGhCliInfo", () => {
|
||||
it("returns gh cli info structure", async () => {
|
||||
// #given
|
||||
// #when checking gh cli info
|
||||
const info = await gh.getGhCliInfo()
|
||||
|
||||
// #then should return valid info structure
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
expect(info.authenticated === true || info.authenticated === false).toBe(true)
|
||||
expect(Array.isArray(info.scopes)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkGhCli", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns warn when gh is not installed", async () => {
|
||||
// #given gh not installed
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// #then should warn (optional)
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("Not installed")
|
||||
expect(result.details).toContain("Install: https://cli.github.com/")
|
||||
})
|
||||
|
||||
it("returns warn when gh is installed but not authenticated", async () => {
|
||||
// #given gh installed but not authenticated
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "2.40.0",
|
||||
path: "/usr/local/bin/gh",
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: "not logged in",
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// #then should warn about auth
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("2.40.0")
|
||||
expect(result.message).toContain("not authenticated")
|
||||
expect(result.details).toContain("Authenticate: gh auth login")
|
||||
})
|
||||
|
||||
it("returns pass when gh is installed and authenticated", async () => {
|
||||
// #given gh installed and authenticated
|
||||
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "2.40.0",
|
||||
path: "/usr/local/bin/gh",
|
||||
authenticated: true,
|
||||
username: "octocat",
|
||||
scopes: ["repo", "read:org"],
|
||||
error: null,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await gh.checkGhCli()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("2.40.0")
|
||||
expect(result.message).toContain("octocat")
|
||||
expect(result.details).toContain("Account: octocat")
|
||||
expect(result.details).toContain("Scopes: repo, read:org")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getGhCliCheckDefinition", () => {
|
||||
it("returns correct check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = gh.getGhCliCheckDefinition()
|
||||
|
||||
// #then should have correct properties
|
||||
expect(def.id).toBe("gh-cli")
|
||||
expect(def.name).toBe("GitHub CLI")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
171
src/cli/doctor/checks/gh.ts
Normal file
171
src/cli/doctor/checks/gh.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { CheckResult, CheckDefinition } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
|
||||
export interface GhCliInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { exists: true, path: output.trim() }
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - binary not found
|
||||
}
|
||||
return { exists: false, path: null }
|
||||
}
|
||||
|
||||
async function getGhVersion(): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
const match = output.match(/gh version (\S+)/)
|
||||
return match?.[1] ?? output.trim().split("\n")[0]
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - version unavailable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function getGhAuthStatus(): Promise<{
|
||||
authenticated: boolean
|
||||
username: string | null
|
||||
scopes: string[]
|
||||
error: string | null
|
||||
}> {
|
||||
try {
|
||||
const proc = Bun.spawn(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
|
||||
})
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
await proc.exited
|
||||
|
||||
const output = stderr || stdout
|
||||
|
||||
if (proc.exitCode === 0) {
|
||||
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
|
||||
const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null
|
||||
|
||||
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
|
||||
const scopes = scopesMatch?.[1]
|
||||
? scopesMatch[1]
|
||||
.split(/,\s*/)
|
||||
.map((s) => s.replace(/['"]/g, "").trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
|
||||
return { authenticated: true, username, scopes, error: null }
|
||||
}
|
||||
|
||||
const errorMatch = output.match(/error[:\s]+(.+)/i)
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: err instanceof Error ? err.message : "Failed to check auth status",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGhCliInfo(): Promise<GhCliInfo> {
|
||||
const binaryCheck = await checkBinaryExists("gh")
|
||||
|
||||
if (!binaryCheck.exists) {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
authenticated: false,
|
||||
username: null,
|
||||
scopes: [],
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryCheck.path,
|
||||
authenticated: authStatus.authenticated,
|
||||
username: authStatus.username,
|
||||
scopes: authStatus.scopes,
|
||||
error: authStatus.error,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkGhCli(): Promise<CheckResult> {
|
||||
const info = await getGhCliInfo()
|
||||
const name = CHECK_NAMES[CHECK_IDS.GH_CLI]
|
||||
|
||||
if (!info.installed) {
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: "Not installed (optional)",
|
||||
details: [
|
||||
"GitHub CLI is used by librarian agent and scripts",
|
||||
"Install: https://cli.github.com/",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.authenticated) {
|
||||
return {
|
||||
name,
|
||||
status: "warn",
|
||||
message: `${info.version ?? "installed"} - not authenticated`,
|
||||
details: [
|
||||
info.path ? `Path: ${info.path}` : null,
|
||||
"Authenticate: gh auth login",
|
||||
info.error ? `Error: ${info.error}` : null,
|
||||
].filter((d): d is string => d !== null),
|
||||
}
|
||||
}
|
||||
|
||||
const details: string[] = []
|
||||
if (info.path) details.push(`Path: ${info.path}`)
|
||||
if (info.username) details.push(`Account: ${info.username}`)
|
||||
if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`)
|
||||
|
||||
return {
|
||||
name,
|
||||
status: "pass",
|
||||
message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`,
|
||||
details: details.length > 0 ? details : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getGhCliCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.GH_CLI,
|
||||
name: CHECK_NAMES[CHECK_IDS.GH_CLI],
|
||||
category: "tools",
|
||||
check: checkGhCli,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { getPluginCheckDefinition } from "./plugin"
|
||||
import { getConfigCheckDefinition } from "./config"
|
||||
import { getAuthCheckDefinitions } from "./auth"
|
||||
import { getDependencyCheckDefinitions } from "./dependencies"
|
||||
import { getGhCliCheckDefinition } from "./gh"
|
||||
import { getLspCheckDefinition } from "./lsp"
|
||||
import { getMcpCheckDefinitions } from "./mcp"
|
||||
import { getVersionCheckDefinition } from "./version"
|
||||
@@ -13,6 +14,7 @@ export * from "./plugin"
|
||||
export * from "./config"
|
||||
export * from "./auth"
|
||||
export * from "./dependencies"
|
||||
export * from "./gh"
|
||||
export * from "./lsp"
|
||||
export * from "./mcp"
|
||||
export * from "./version"
|
||||
@@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
getConfigCheckDefinition(),
|
||||
...getAuthCheckDefinitions(),
|
||||
...getDependencyCheckDefinitions(),
|
||||
getGhCliCheckDefinition(),
|
||||
getLspCheckDefinition(),
|
||||
...getMcpCheckDefinitions(),
|
||||
getVersionCheckDefinition(),
|
||||
|
||||
@@ -27,6 +27,7 @@ export const CHECK_IDS = {
|
||||
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
|
||||
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
|
||||
DEP_COMMENT_CHECKER: "dep-comment-checker",
|
||||
GH_CLI: "gh-cli",
|
||||
LSP_SERVERS: "lsp-servers",
|
||||
MCP_BUILTIN: "mcp-builtin",
|
||||
MCP_USER: "mcp-user",
|
||||
@@ -43,6 +44,7 @@ export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
|
||||
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
|
||||
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
|
||||
[CHECK_IDS.GH_CLI]: "GitHub CLI",
|
||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||
|
||||
@@ -331,6 +331,17 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
|
||||
console.log(` Run ${color.cyan("opencode")} to start!`)
|
||||
console.log()
|
||||
|
||||
printBox(
|
||||
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
|
||||
`All features work like magic—parallel agents, background tasks,\n` +
|
||||
`deep exploration, and relentless execution until completion.`,
|
||||
"🪄 The Magic Word"
|
||||
)
|
||||
|
||||
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
|
||||
console.log(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
|
||||
console.log()
|
||||
console.log(color.dim("oMoMoMoMo... Enjoy!"))
|
||||
console.log()
|
||||
|
||||
@@ -450,6 +461,16 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
|
||||
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
||||
|
||||
p.note(
|
||||
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
|
||||
`All features work like magic—parallel agents, background tasks,\n` +
|
||||
`deep exploration, and relentless execution until completion.`,
|
||||
"🪄 The Magic Word"
|
||||
)
|
||||
|
||||
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
|
||||
p.log.message(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
|
||||
|
||||
p.outro(color.green("oMoMoMoMo... Enjoy!"))
|
||||
|
||||
return 0
|
||||
|
||||
@@ -26,6 +26,10 @@ export const BuiltinAgentNameSchema = z.enum([
|
||||
"multimodal-looker",
|
||||
])
|
||||
|
||||
export const BuiltinSkillNameSchema = z.enum([
|
||||
"playwright",
|
||||
])
|
||||
|
||||
export const OverridableAgentNameSchema = z.enum([
|
||||
"build",
|
||||
"plan",
|
||||
@@ -69,6 +73,7 @@ export const HookNameSchema = z.enum([
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
@@ -167,7 +172,7 @@ export const DynamicContextPruningConfigSchema = z.object({
|
||||
export const ExperimentalConfigSchema = z.object({
|
||||
aggressive_truncation: z.boolean().optional(),
|
||||
auto_resume: z.boolean().optional(),
|
||||
/** Enable preemptive compaction at threshold (default: false) */
|
||||
/** Enable preemptive compaction at threshold (default: true since v2.9.0) */
|
||||
preemptive_compaction: z.boolean().optional(),
|
||||
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
|
||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||
@@ -230,6 +235,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
@@ -249,6 +255,7 @@ export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>
|
||||
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
||||
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
|
||||
@@ -2,79 +2,65 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Claude Code compatibility layer and core feature modules. Enables Claude Code configs/commands/skills/MCPs/hooks to work seamlessly in OpenCode.
|
||||
Claude Code compatibility layer + core feature modules. Commands, skills, agents, MCPs, hooks from Claude Code work seamlessly.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Background task management
|
||||
│ ├── manager.ts # Task lifecycle, notifications
|
||||
│ ├── manager.test.ts
|
||||
│ └── types.ts
|
||||
├── builtin-commands/ # Built-in slash command definitions
|
||||
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
|
||||
├── background-agent/ # Task lifecycle, notifications (460 lines)
|
||||
├── builtin-commands/ # Built-in slash commands
|
||||
├── builtin-skills/ # Built-in skills (playwright)
|
||||
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # .mcp.json files
|
||||
│ └── env-expander.ts # ${VAR} expansion
|
||||
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
|
||||
├── claude-code-plugin-loader/ # installed_plugins.json (484 lines)
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
|
||||
├── opencode-skill-loader/ # Skills from OpenCode + Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers in skill YAML
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
## LOADER PRIORITY
|
||||
|
||||
Each loader reads from multiple directories (highest priority first):
|
||||
|
||||
| Loader | Priority Order |
|
||||
|--------|---------------|
|
||||
| Loader | Priority (highest first) |
|
||||
|--------|--------------------------|
|
||||
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
|
||||
| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
|
||||
| Agents | `.claude/agents/` > `~/.claude/agents/` |
|
||||
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
|
||||
|
||||
## HOW TO ADD A LOADER
|
||||
|
||||
1. Create directory: `src/features/claude-code-my-loader/`
|
||||
2. Create files:
|
||||
- `loader.ts`: Main loader logic with `load()` function
|
||||
- `types.ts`: TypeScript interfaces
|
||||
- `index.ts`: Barrel export
|
||||
3. Pattern: Read from multiple dirs, merge with priority, return normalized config
|
||||
|
||||
## BACKGROUND AGENT SPECIFICS
|
||||
|
||||
- **Task lifecycle**: pending → running → completed/failed
|
||||
- **Notifications**: OS notification on task complete (configurable)
|
||||
- **Result retrieval**: `background_output` tool with task_id
|
||||
- **Cancellation**: `background_cancel` with task_id or all=true
|
||||
|
||||
## CONFIG TOGGLES
|
||||
|
||||
Disable features in `oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"claude_code": {
|
||||
"mcp": false, // Skip .mcp.json loading
|
||||
"commands": false, // Skip commands/*.md loading
|
||||
"skills": false, // Skip skills/*/SKILL.md loading
|
||||
"agents": false, // Skip agents/*.md loading
|
||||
"mcp": false, // Skip .mcp.json
|
||||
"commands": false, // Skip commands/*.md
|
||||
"skills": false, // Skip skills/*/SKILL.md
|
||||
"agents": false, // Skip agents/*.md
|
||||
"hooks": false // Skip settings.json hooks
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## HOOK MESSAGE INJECTOR
|
||||
## BACKGROUND AGENT
|
||||
|
||||
- **Purpose**: Inject system messages into conversation at specific points
|
||||
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
|
||||
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
|
||||
- Lifecycle: pending → running → completed/failed
|
||||
- OS notification on complete
|
||||
- `background_output` to retrieve results
|
||||
- `background_cancel` with task_id or all=true
|
||||
|
||||
## ANTI-PATTERNS (FEATURES)
|
||||
## SKILL MCP
|
||||
|
||||
- **Blocking on load**: Loaders run at startup, keep them fast
|
||||
- **No error handling**: Always try/catch, log failures, return empty on error
|
||||
- **Ignoring priority**: Higher priority dirs must override lower
|
||||
- **Modifying user files**: Loaders read-only, never write to ~/.claude/
|
||||
- MCP servers embedded in skill YAML frontmatter
|
||||
- Lazy client loading, session-scoped cleanup
|
||||
- `skill_mcp` tool exposes capabilities
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Blocking on load (loaders run at startup)
|
||||
- No error handling (always try/catch)
|
||||
- Ignoring priority order
|
||||
- Writing to ~/.claude/ (read-only)
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import type { BuiltinSkill } from "./types"
|
||||
|
||||
export function createBuiltinSkills(): BuiltinSkill[] {
|
||||
return []
|
||||
const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
mcpConfig: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function createBuiltinSkills(): BuiltinSkill[] {
|
||||
return [playwrightSkill]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export interface BuiltinSkill {
|
||||
name: string
|
||||
description: string
|
||||
@@ -10,4 +12,5 @@ export interface BuiltinSkill {
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
mcpConfig?: SkillMcpConfig
|
||||
}
|
||||
|
||||
@@ -1,24 +1,59 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
|
||||
function loadCommandsFromDir(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
visited: Set<string> = new Set(),
|
||||
prefix: string = ""
|
||||
): LoadedCommand[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = realpathSync(commandsDir)
|
||||
} catch (error) {
|
||||
log(`Failed to resolve command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
if (visited.has(realPath)) {
|
||||
return []
|
||||
}
|
||||
visited.add(realPath)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
log(`Failed to read command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const subDirPath = join(commandsDir, entry.name)
|
||||
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
|
||||
commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix))
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
const baseCommandName = basename(entry.name, ".md")
|
||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
@@ -43,6 +78,7 @@ $ARGUMENTS
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
@@ -51,7 +87,8 @@ $ARGUMENTS
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log(`Failed to parse command: ${commandPath}`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
/**
|
||||
* Handoff definition for command workflows.
|
||||
* Based on speckit's handoff pattern for multi-agent orchestration.
|
||||
* @see https://github.com/github/spec-kit
|
||||
*/
|
||||
export interface HandoffDefinition {
|
||||
/** Human-readable label for the handoff action */
|
||||
label: string
|
||||
/** Target agent/command identifier (e.g., "speckit.tasks") */
|
||||
agent: string
|
||||
/** Pre-filled prompt text for the handoff */
|
||||
prompt: string
|
||||
/** If true, automatically executes after command completion; if false, shows as suggestion */
|
||||
send?: boolean
|
||||
}
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
@@ -8,6 +24,8 @@ export interface CommandDefinition {
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
/** Handoff definitions for workflow transitions */
|
||||
handoffs?: HandoffDefinition[]
|
||||
}
|
||||
|
||||
export interface CommandFrontmatter {
|
||||
@@ -16,6 +34,8 @@ export interface CommandFrontmatter {
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
/** Handoff definitions for workflow transitions */
|
||||
handoffs?: HandoffDefinition[]
|
||||
}
|
||||
|
||||
export interface LoadedCommand {
|
||||
|
||||
162
src/features/claude-code-mcp-loader/loader.test.ts
Normal file
162
src/features/claude-code-mcp-loader/loader.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
|
||||
|
||||
describe("getSystemMcpServerNames", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it("returns empty set when no .mcp.json files exist", async () => {
|
||||
// #given
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names).toBeInstanceOf(Set)
|
||||
expect(names.size).toBe(0)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns server names from project .mcp.json", async () => {
|
||||
// #given
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
},
|
||||
sqlite: {
|
||||
command: "uvx",
|
||||
args: ["mcp-server-sqlite"],
|
||||
},
|
||||
},
|
||||
}
|
||||
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("playwright")).toBe(true)
|
||||
expect(names.has("sqlite")).toBe(true)
|
||||
expect(names.size).toBe(2)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns server names from .claude/.mcp.json", async () => {
|
||||
// #given
|
||||
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
memory: {
|
||||
command: "npx",
|
||||
args: ["-y", "@anthropic-ai/mcp-server-memory"],
|
||||
},
|
||||
},
|
||||
}
|
||||
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(mcpConfig))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("memory")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("excludes disabled MCP servers", async () => {
|
||||
// #given
|
||||
const mcpConfig = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"],
|
||||
disabled: true,
|
||||
},
|
||||
active: {
|
||||
command: "npx",
|
||||
args: ["some-mcp"],
|
||||
},
|
||||
},
|
||||
}
|
||||
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("playwright")).toBe(false)
|
||||
expect(names.has("active")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("merges server names from multiple .mcp.json files", async () => {
|
||||
// #given
|
||||
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
|
||||
|
||||
const projectMcp = {
|
||||
mcpServers: {
|
||||
playwright: { command: "npx", args: ["@playwright/mcp@latest"] },
|
||||
},
|
||||
}
|
||||
const localMcp = {
|
||||
mcpServers: {
|
||||
memory: { command: "npx", args: ["-y", "@anthropic-ai/mcp-server-memory"] },
|
||||
},
|
||||
}
|
||||
|
||||
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(projectMcp))
|
||||
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(localMcp))
|
||||
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
// #when
|
||||
const { getSystemMcpServerNames } = await import("./loader")
|
||||
const names = getSystemMcpServerNames()
|
||||
|
||||
// #then
|
||||
expect(names.has("playwright")).toBe(true)
|
||||
expect(names.has("memory")).toBe(true)
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type {
|
||||
@@ -42,6 +42,30 @@ async function loadMcpConfigFile(
|
||||
}
|
||||
}
|
||||
|
||||
export function getSystemMcpServerNames(): Set<string> {
|
||||
const names = new Set<string>()
|
||||
const paths = getMcpConfigPaths()
|
||||
|
||||
for (const { path } of paths) {
|
||||
if (!existsSync(path)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(path, "utf-8")
|
||||
const config = JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
if (!config?.mcpServers) continue
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
if (serverConfig.disabled) continue
|
||||
names.add(name)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
export async function loadMcpConfigs(): Promise<McpLoadResult> {
|
||||
const servers: McpLoadResult["servers"] = {}
|
||||
const loadedServers: LoadedMcpServer[] = []
|
||||
|
||||
273
src/features/opencode-skill-loader/loader.test.ts
Normal file
273
src/features/opencode-skill-loader/loader.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "skill-loader-test-" + Date.now())
|
||||
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
||||
|
||||
function createTestSkill(name: string, content: string, mcpJson?: object): string {
|
||||
const skillDir = join(SKILLS_DIR, name)
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
writeFileSync(skillPath, content)
|
||||
if (mcpJson) {
|
||||
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
|
||||
}
|
||||
return skillDir
|
||||
}
|
||||
|
||||
describe("skill loader MCP parsing", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("parseSkillMcpConfig", () => {
|
||||
it("parses skill with nested MCP config", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill with MCP
|
||||
mcp:
|
||||
sqlite:
|
||||
command: uvx
|
||||
args:
|
||||
- mcp-server-sqlite
|
||||
- --db-path
|
||||
- ./data.db
|
||||
memory:
|
||||
command: npx
|
||||
args: [-y, "@anthropic-ai/mcp-server-memory"]
|
||||
---
|
||||
This is the skill body.
|
||||
`
|
||||
createTestSkill("test-mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "test-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
expect(skill?.mcpConfig?.sqlite?.args).toEqual([
|
||||
"mcp-server-sqlite",
|
||||
"--db-path",
|
||||
"./data.db"
|
||||
])
|
||||
expect(skill?.mcpConfig?.memory).toBeDefined()
|
||||
expect(skill?.mcpConfig?.memory?.command).toBe("npx")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("returns undefined mcpConfig for skill without MCP", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: simple-skill
|
||||
description: A simple skill without MCP
|
||||
---
|
||||
This is a simple skill.
|
||||
`
|
||||
createTestSkill("simple-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "simple-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("preserves env var placeholders without expansion", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: env-skill
|
||||
mcp:
|
||||
api-server:
|
||||
command: node
|
||||
args: [server.js]
|
||||
env:
|
||||
API_KEY: "\${API_KEY}"
|
||||
DB_PATH: "\${HOME}/data.db"
|
||||
---
|
||||
Skill with env vars.
|
||||
`
|
||||
createTestSkill("env-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "env-skill")
|
||||
|
||||
// #then
|
||||
expect(skill?.mcpConfig?.["api-server"]?.env?.API_KEY).toBe("${API_KEY}")
|
||||
expect(skill?.mcpConfig?.["api-server"]?.env?.DB_PATH).toBe("${HOME}/data.db")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("handles malformed YAML gracefully", async () => {
|
||||
// #given - malformed YAML causes entire frontmatter to fail parsing
|
||||
const skillContent = `---
|
||||
name: bad-yaml
|
||||
mcp: [this is not valid yaml for mcp
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
createTestSkill("bad-yaml-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
// #then - when YAML fails, skill uses directory name as fallback
|
||||
const skill = skills.find(s => s.name === "bad-yaml-skill")
|
||||
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("mcp.json file loading (AmpCode compat)", () => {
|
||||
it("loads MCP config from mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: ampcode-skill
|
||||
description: Skill with mcp.json
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp@latest"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("ampcode-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "ampcode-skill")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
|
||||
expect(skill?.mcpConfig?.playwright?.args).toEqual(["@playwright/mcp@latest"])
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("mcp.json takes priority over YAML frontmatter", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: priority-skill
|
||||
mcp:
|
||||
from-yaml:
|
||||
command: yaml-cmd
|
||||
args: [yaml-arg]
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
"from-json": {
|
||||
command: "json-cmd",
|
||||
args: ["json-arg"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("priority-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "priority-skill")
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers wrapper", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: direct-format
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
sqlite: {
|
||||
command: "uvx",
|
||||
args: ["mcp-server-sqlite"]
|
||||
}
|
||||
}
|
||||
createTestSkill("direct-format", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkills } = await import("./loader")
|
||||
const originalCwd = process.cwd()
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "direct-format")
|
||||
|
||||
// #then
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
} finally {
|
||||
process.chdir(originalCwd)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,21 +1,58 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { homedir } from "os"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!frontmatterMatch) return undefined
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
||||
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
|
||||
return parsed.mcp as SkillMcpConfig
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
|
||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||
if (!existsSync(mcpJsonPath)) return undefined
|
||||
|
||||
try {
|
||||
const content = readFileSync(mcpJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
|
||||
// AmpCode format: { "mcpServers": { "name": { ... } } }
|
||||
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
|
||||
return parsed.mcpServers as SkillMcpConfig
|
||||
}
|
||||
|
||||
// Also support direct format: { "name": { command: ..., args: ... } }
|
||||
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
|
||||
const hasCommandField = Object.values(parsed).some(
|
||||
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
|
||||
)
|
||||
if (hasCommandField) {
|
||||
return parsed as SkillMcpConfig
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a skill from a markdown file path.
|
||||
*
|
||||
* @param skillPath - Path to the skill file (SKILL.md or {name}.md)
|
||||
* @param resolvedPath - Directory for file reference resolution (@path references)
|
||||
* @param defaultName - Fallback name if not specified in frontmatter
|
||||
* @param scope - Source scope for priority ordering
|
||||
*/
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
@@ -30,6 +67,9 @@ function loadSkillFromPath(
|
||||
try {
|
||||
const content = readFileSync(skillPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
@@ -67,6 +107,7 @@ $ARGUMENTS
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
|
||||
@@ -21,7 +21,7 @@ const SCOPE_PRIORITY: Record<SkillScope, number> = {
|
||||
function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
|
||||
const definition: CommandDefinition = {
|
||||
name: builtin.name,
|
||||
description: `(builtin - Skill) ${builtin.description}`,
|
||||
description: `(opencode - Skill) ${builtin.description}`,
|
||||
template: builtin.template,
|
||||
model: builtin.model,
|
||||
agent: builtin.agent,
|
||||
@@ -37,6 +37,7 @@ function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
|
||||
compatibility: builtin.compatibility,
|
||||
metadata: builtin.metadata as Record<string, string> | undefined,
|
||||
allowedTools: builtin.allowedTools,
|
||||
mcpConfig: builtin.mcpConfig,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
@@ -13,6 +14,7 @@ export interface SkillMetadata {
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
"allowed-tools"?: string
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
@@ -25,4 +27,5 @@ export interface LoadedSkill {
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
allowedTools?: string[]
|
||||
mcpConfig?: SkillMcpConfig
|
||||
}
|
||||
|
||||
2
src/features/skill-mcp-manager/index.ts
Normal file
2
src/features/skill-mcp-manager/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { SkillMcpManager } from "./manager"
|
||||
159
src/features/skill-mcp-manager/manager.test.ts
Normal file
159
src/features/skill-mcp-manager/manager.test.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
|
||||
import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
describe("SkillMcpManager", () => {
|
||||
let manager: SkillMcpManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await manager.disconnectAll()
|
||||
})
|
||||
|
||||
describe("getOrCreateClient", () => {
|
||||
it("throws error when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing required 'command' field/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful error message with example when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/my-mcp[\s\S]*data-skill[\s\S]*Example/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnectSession", () => {
|
||||
it("removes all clients for a specific session", async () => {
|
||||
// #given
|
||||
const session1Info: SkillMcpClientInfo = {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const session2Info: SkillMcpClientInfo = {
|
||||
serverName: "server1",
|
||||
skillName: "skill1",
|
||||
sessionID: "session-2",
|
||||
}
|
||||
|
||||
// #when
|
||||
await manager.disconnectSession("session-1")
|
||||
|
||||
// #then
|
||||
expect(manager.isConnected(session1Info)).toBe(false)
|
||||
expect(manager.isConnected(session2Info)).toBe(false)
|
||||
})
|
||||
|
||||
it("does not throw when session has no clients", async () => {
|
||||
// #given / #when / #then
|
||||
await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("disconnectAll", () => {
|
||||
it("clears all clients", async () => {
|
||||
// #given - no actual clients connected (would require real MCP server)
|
||||
|
||||
// #when
|
||||
await manager.disconnectAll()
|
||||
|
||||
// #then
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("isConnected", () => {
|
||||
it("returns false for unconnected server", () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "unknown",
|
||||
skillName: "test",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
expect(manager.isConnected(info)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConnectedServers", () => {
|
||||
it("returns empty array when no servers connected", () => {
|
||||
// #given / #when / #then
|
||||
expect(manager.getConnectedServers()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("environment variable handling", () => {
|
||||
it("always inherits process.env even when config.env is undefined", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const configWithoutEnv: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when - attempt connection (will fail but exercises env merging code path)
|
||||
// #then - should not throw "undefined" related errors for env
|
||||
try {
|
||||
await manager.getOrCreateClient(info, configWithoutEnv)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
expect(message).not.toContain("env")
|
||||
expect(message).not.toContain("undefined")
|
||||
}
|
||||
})
|
||||
|
||||
it("overlays config.env on top of inherited process.env", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-2",
|
||||
}
|
||||
const configWithEnv: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
env: {
|
||||
CUSTOM_VAR: "custom_value",
|
||||
},
|
||||
}
|
||||
|
||||
// #when - attempt connection
|
||||
// #then - should not throw, env merging should work
|
||||
try {
|
||||
await manager.getOrCreateClient(info, configWithEnv)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
expect(message).toContain("Failed to connect")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
213
src/features/skill-mcp-manager/manager.ts
Normal file
213
src/features/skill-mcp-manager/manager.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
|
||||
private getClientKey(info: SkillMcpClientInfo): string {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
async getOrCreateClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
|
||||
if (existing) {
|
||||
return existing.client
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(config)
|
||||
const client = await this.createClient(info, expandedConfig)
|
||||
return client
|
||||
}
|
||||
|
||||
private async createClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
|
||||
if (!config.command) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" is missing required 'command' field.\n\n` +
|
||||
`The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` +
|
||||
`Example:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` command: npx\n` +
|
||||
` args: [-y, @some/mcp-server]`
|
||||
)
|
||||
}
|
||||
|
||||
const command = config.command
|
||||
const args = config.args || []
|
||||
|
||||
// Always inherit parent process environment
|
||||
const mergedEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) mergedEnv[key] = value
|
||||
}
|
||||
// Overlay with skill-specific env vars if present
|
||||
if (config.env) {
|
||||
Object.assign(mergedEnv, config.env)
|
||||
}
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: mergedEnv,
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
)
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
`Command: ${command} ${args.join(" ")}\n` +
|
||||
`Reason: ${errorMessage}\n\n` +
|
||||
`Hints:\n` +
|
||||
` - Ensure the command is installed and available in PATH\n` +
|
||||
` - Check if the MCP server package exists\n` +
|
||||
` - Verify the args are correct for this server`
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName })
|
||||
return client
|
||||
}
|
||||
|
||||
async disconnectSession(sessionID: string): Promise<void> {
|
||||
const keysToRemove: string[] = []
|
||||
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
if (key.startsWith(`${sessionID}:`)) {
|
||||
keysToRemove.push(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore close errors - process may already be terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
this.clients.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectAll(): Promise<void> {
|
||||
for (const [, managed] of this.clients.entries()) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
}
|
||||
this.clients.clear()
|
||||
}
|
||||
|
||||
async listTools(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext
|
||||
): Promise<Tool[]> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.listTools()
|
||||
return result.tools
|
||||
}
|
||||
|
||||
async listResources(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext
|
||||
): Promise<Resource[]> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.listResources()
|
||||
return result.resources
|
||||
}
|
||||
|
||||
async listPrompts(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext
|
||||
): Promise<Prompt[]> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.listPrompts()
|
||||
return result.prompts
|
||||
}
|
||||
|
||||
async callTool(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext,
|
||||
name: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.callTool({ name, arguments: args })
|
||||
return result.content
|
||||
}
|
||||
|
||||
async readResource(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext,
|
||||
uri: string
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.readResource({ uri })
|
||||
return result.contents
|
||||
}
|
||||
|
||||
async getPrompt(
|
||||
info: SkillMcpClientInfo,
|
||||
context: SkillMcpServerContext,
|
||||
name: string,
|
||||
args: Record<string, string>
|
||||
): Promise<unknown> {
|
||||
const client = await this.getOrCreateClientWithRetry(info, context.config)
|
||||
const result = await client.getPrompt({ name, arguments: args })
|
||||
return result.messages
|
||||
}
|
||||
|
||||
private async getOrCreateClientWithRetry(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
try {
|
||||
return await this.getOrCreateClient(info, config)
|
||||
} catch (error) {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
this.clients.delete(key)
|
||||
return await this.getOrCreateClient(info, config)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getConnectedServers(): string[] {
|
||||
return Array.from(this.clients.keys())
|
||||
}
|
||||
|
||||
isConnected(info: SkillMcpClientInfo): boolean {
|
||||
return this.clients.has(this.getClientKey(info))
|
||||
}
|
||||
}
|
||||
14
src/features/skill-mcp-manager/types.ts
Normal file
14
src/features/skill-mcp-manager/types.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
|
||||
|
||||
export interface SkillMcpClientInfo {
|
||||
serverName: string
|
||||
skillName: string
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
export interface SkillMcpServerContext {
|
||||
config: ClaudeCodeMcpServer
|
||||
skillName: string
|
||||
}
|
||||
@@ -2,83 +2,65 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce rules, recover from errors, notify on events.
|
||||
22 lifecycle hooks intercepting/modifying agent behavior. Context injection, error recovery, output control, notifications.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
hooks/
|
||||
├── agent-usage-reminder/ # Remind to use specialized agents
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact Claude at token limit
|
||||
├── auto-update-checker/ # Version update notifications
|
||||
├── background-notification/ # OS notify on background task complete
|
||||
├── claude-code-hooks/ # Claude Code settings.json integration
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact at token limit (554 lines)
|
||||
├── auto-slash-command/ # Detect and execute /command patterns
|
||||
├── auto-update-checker/ # Version notifications, startup toast
|
||||
├── background-notification/ # OS notify on task complete
|
||||
├── claude-code-hooks/ # settings.json PreToolUse/PostToolUse/etc
|
||||
├── comment-checker/ # Prevent excessive AI comments
|
||||
│ ├── filters/ # Filtering rules (docstring, directive, bdd, etc.)
|
||||
│ └── output/ # Output formatting
|
||||
├── compaction-context-injector/ # Inject context during compaction
|
||||
├── directory-agents-injector/ # Auto-inject AGENTS.md files
|
||||
├── directory-readme-injector/ # Auto-inject README.md files
|
||||
│ └── filters/ # docstring, directive, bdd, etc
|
||||
├── compaction-context-injector/ # Preserve context during compaction
|
||||
├── directory-agents-injector/ # Auto-inject AGENTS.md
|
||||
├── directory-readme-injector/ # Auto-inject README.md
|
||||
├── empty-message-sanitizer/ # Sanitize empty messages
|
||||
├── interactive-bash-session/ # Tmux session management
|
||||
├── keyword-detector/ # Detect ultrawork/search keywords
|
||||
├── non-interactive-env/ # CI/headless environment handling
|
||||
├── preemptive-compaction/ # Pre-emptive session compaction
|
||||
├── keyword-detector/ # ultrawork/search keyword activation
|
||||
├── non-interactive-env/ # CI/headless handling
|
||||
├── preemptive-compaction/ # Pre-emptive at 85% usage
|
||||
├── ralph-loop/ # Self-referential dev loop
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from session errors
|
||||
├── session-recovery/ # Recover from errors (430 lines)
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
├── thinking-block-validator/ # Validate thinking blocks in messages
|
||||
├── context-window-monitor.ts # Monitor context usage (standalone)
|
||||
├── empty-task-response-detector.ts
|
||||
├── session-notification.ts # OS notify on idle (standalone)
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion (standalone)
|
||||
└── tool-output-truncator.ts # Truncate verbose outputs (standalone)
|
||||
├── agent-usage-reminder/ # Remind to use specialists
|
||||
├── context-window-monitor.ts # Monitor usage (standalone)
|
||||
├── session-notification.ts # OS notify on idle
|
||||
├── todo-continuation-enforcer.ts # Force TODO completion
|
||||
└── tool-output-truncator.ts # Truncate verbose outputs
|
||||
```
|
||||
|
||||
## HOOK CATEGORIES
|
||||
|
||||
| Category | Hooks | Purpose |
|
||||
|----------|-------|---------|
|
||||
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
|
||||
| Session Management | session-recovery, anthropic-context-window-limit-recovery, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
|
||||
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
|
||||
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
|
||||
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
|
||||
| Environment | non-interactive-env, interactive-bash-session, context-window-monitor | Adapt to runtime environment |
|
||||
| Compatibility | claude-code-hooks | Claude Code settings.json support |
|
||||
|
||||
## HOW TO ADD A HOOK
|
||||
|
||||
1. Create directory: `src/hooks/my-hook/`
|
||||
2. Create files:
|
||||
- `index.ts`: Export `createMyHook(input: PluginInput)`
|
||||
- `constants.ts`: Hook name constant
|
||||
- `types.ts`: TypeScript interfaces (optional)
|
||||
- `storage.ts`: Persistent state (optional)
|
||||
3. Return event handlers: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
|
||||
4. Export from `src/hooks/index.ts`
|
||||
5. Register in main plugin
|
||||
|
||||
## HOOK EVENTS
|
||||
|
||||
| Event | Timing | Can Block | Use Case |
|
||||
|-------|--------|-----------|----------|
|
||||
| PreToolUse | Before tool exec | Yes | Validate, modify input |
|
||||
| PostToolUse | After tool exec | No | Add context, warnings |
|
||||
| UserPromptSubmit | On user prompt | Yes | Inject messages, block |
|
||||
| PreToolUse | Before tool | Yes | Validate, modify input |
|
||||
| PostToolUse | After tool | No | Add context, warnings |
|
||||
| UserPromptSubmit | On prompt | Yes | Inject messages, block |
|
||||
| Stop | Session idle | No | Inject follow-ups |
|
||||
| onSummarize | During compaction | No | Preserve critical context |
|
||||
| onSummarize | Compaction | No | Preserve context |
|
||||
|
||||
## COMMON PATTERNS
|
||||
## HOW TO ADD
|
||||
|
||||
- **Storage**: Use `storage.ts` with JSON file for persistent state across sessions
|
||||
- **Once-per-session**: Track injected paths in Set to avoid duplicate injection
|
||||
- **Message injection**: Return `{ messages: [...] }` from event handlers
|
||||
- **Blocking**: Return `{ blocked: true, message: "reason" }` from PreToolUse
|
||||
1. Create `src/hooks/my-hook/`
|
||||
2. Files: `index.ts` (createMyHook), `constants.ts`, `types.ts` (optional)
|
||||
3. Return: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
|
||||
4. Export from `src/hooks/index.ts`
|
||||
|
||||
## ANTI-PATTERNS (HOOKS)
|
||||
## PATTERNS
|
||||
|
||||
- **Heavy computation** in PreToolUse: Slows every tool call
|
||||
- **Blocking without clear reason**: Always provide actionable message
|
||||
- **Duplicate injection**: Track what's already injected per session
|
||||
- **Ignoring errors**: Always try/catch, log failures, don't crash session
|
||||
- **Storage**: JSON file for persistent state across sessions
|
||||
- **Once-per-session**: Track injected paths in Set
|
||||
- **Message injection**: Return `{ messages: [...] }`
|
||||
- **Blocking**: Return `{ blocked: true, message: "..." }` from PreToolUse
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Heavy computation in PreToolUse (slows every tool call)
|
||||
- Blocking without actionable message
|
||||
- Duplicate injection (track what's injected)
|
||||
- Missing try/catch (don't crash session)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
|
||||
import { executeCompact } from "./executor"
|
||||
import type { AutoCompactState } from "./types"
|
||||
import * as storage from "./storage"
|
||||
|
||||
describe("executeCompact lock management", () => {
|
||||
let autoCompactState: AutoCompactState
|
||||
@@ -224,4 +225,86 @@ describe("executeCompact lock management", () => {
|
||||
// The continuation happens in setTimeout, but lock is cleared in finally before that
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
test("falls through to summarize when truncation is insufficient", async () => {
|
||||
// #given: Over token limit with truncation returning insufficient
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
|
||||
success: true,
|
||||
sufficient: false,
|
||||
truncatedCount: 3,
|
||||
totalBytesRemoved: 10000,
|
||||
targetBytesToRemove: 50000,
|
||||
truncatedTools: [
|
||||
{ toolName: "Grep", originalSize: 5000 },
|
||||
{ toolName: "Read", originalSize: 3000 },
|
||||
{ toolName: "Bash", originalSize: 2000 },
|
||||
],
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// #then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
// #then: Summarize should be called (fall through from insufficient truncation)
|
||||
expect(mockClient.session.summarize).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: { id: sessionID },
|
||||
body: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
}),
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
|
||||
truncateSpy.mockRestore()
|
||||
})
|
||||
|
||||
test("does NOT call summarize when truncation is sufficient", async () => {
|
||||
// #given: Over token limit with truncation returning sufficient
|
||||
autoCompactState.errorDataBySession.set(sessionID, {
|
||||
errorType: "token_limit",
|
||||
currentTokens: 250000,
|
||||
maxTokens: 200000,
|
||||
})
|
||||
|
||||
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
|
||||
success: true,
|
||||
sufficient: true,
|
||||
truncatedCount: 5,
|
||||
totalBytesRemoved: 60000,
|
||||
targetBytesToRemove: 50000,
|
||||
truncatedTools: [
|
||||
{ toolName: "Grep", originalSize: 30000 },
|
||||
{ toolName: "Read", originalSize: 30000 },
|
||||
],
|
||||
})
|
||||
|
||||
// #when: Execute compaction
|
||||
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
|
||||
|
||||
// Wait for setTimeout callback
|
||||
await new Promise((resolve) => setTimeout(resolve, 600))
|
||||
|
||||
// #then: Truncation was attempted
|
||||
expect(truncateSpy).toHaveBeenCalled()
|
||||
|
||||
// #then: Summarize should NOT be called (early return from sufficient truncation)
|
||||
expect(mockClient.session.summarize).not.toHaveBeenCalled()
|
||||
|
||||
// #then: prompt_async should be called (Continue after successful truncation)
|
||||
expect(mockClient.session.prompt_async).toHaveBeenCalled()
|
||||
|
||||
// #then: Lock should be cleared
|
||||
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
|
||||
|
||||
truncateSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -158,6 +158,8 @@ export async function getLastAssistant(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
function clearSessionState(
|
||||
autoCompactState: AutoCompactState,
|
||||
sessionID: string,
|
||||
@@ -295,17 +297,16 @@ export async function executeCompact(
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID);
|
||||
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
|
||||
|
||||
// DCP FIRST - run before any other recovery attempts when token limit exceeded (controlled by dcp-for-compaction hook)
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (
|
||||
dcpForCompaction !== false &&
|
||||
!dcpState.attempted &&
|
||||
const isOverLimit =
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
errorData.currentTokens > errorData.maxTokens;
|
||||
|
||||
// PHASE 1: DCP (Dynamic Context Pruning) - prune duplicate tool calls first
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (dcpForCompaction !== false && !dcpState.attempted && isOverLimit) {
|
||||
dcpState.attempted = true;
|
||||
log("[auto-compact] DCP triggered FIRST on token limit error", {
|
||||
log("[auto-compact] PHASE 1: DCP triggered on token limit error", {
|
||||
sessionID,
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
@@ -314,19 +315,25 @@ export async function executeCompact(
|
||||
const dcpConfig = experimental?.dynamic_context_pruning ?? {
|
||||
enabled: true,
|
||||
notification: "detailed" as const,
|
||||
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
|
||||
protected_tools: [
|
||||
"task",
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
],
|
||||
};
|
||||
|
||||
try {
|
||||
const pruningResult = await executeDynamicContextPruning(
|
||||
sessionID,
|
||||
dcpConfig,
|
||||
client
|
||||
client,
|
||||
);
|
||||
|
||||
if (pruningResult.itemsPruned > 0) {
|
||||
dcpState.itemsPruned = pruningResult.itemsPruned;
|
||||
log("[auto-compact] DCP successful, proceeding to compaction", {
|
||||
log("[auto-compact] DCP successful, proceeding to truncation", {
|
||||
itemsPruned: pruningResult.itemsPruned,
|
||||
tokensSaved: pruningResult.totalTokensSaved,
|
||||
});
|
||||
@@ -335,56 +342,13 @@ export async function executeCompact(
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Running compaction...`,
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Proceeding to truncation...`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// After DCP, immediately try summarize
|
||||
const providerID = msg.providerID as string | undefined;
|
||||
const modelID = msg.modelID as string | undefined;
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Summarizing session after DCP...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch (summarizeError) {
|
||||
log("[auto-compact] summarize after DCP failed, continuing recovery", {
|
||||
error: String(summarizeError),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Continue to PHASE 2 (truncation) instead of summarizing immediately
|
||||
} else {
|
||||
log("[auto-compact] DCP did not prune any items", { sessionID });
|
||||
}
|
||||
@@ -393,14 +357,12 @@ export async function executeCompact(
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 2: Aggressive Truncation - always try when over limit (not experimental-only)
|
||||
if (
|
||||
experimental?.aggressive_truncation &&
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens &&
|
||||
isOverLimit &&
|
||||
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
|
||||
) {
|
||||
log("[auto-compact] aggressive truncation triggered (experimental)", {
|
||||
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
|
||||
@@ -422,16 +384,16 @@ export async function executeCompact(
|
||||
.join(", ");
|
||||
const statusMsg = aggressiveResult.sufficient
|
||||
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`;
|
||||
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: aggressiveResult.sufficient
|
||||
? "Aggressive Truncation"
|
||||
? "Truncation Complete"
|
||||
: "Partial Truncation",
|
||||
message: `${statusMsg}: ${toolNames}`,
|
||||
variant: "warning",
|
||||
variant: aggressiveResult.sufficient ? "success" : "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
@@ -439,7 +401,10 @@ export async function executeCompact(
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||
|
||||
// Only return early if truncation was sufficient to get under token limit
|
||||
// Otherwise fall through to PHASE 3 (Summarize)
|
||||
if (aggressiveResult.sufficient) {
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
@@ -451,87 +416,16 @@ export async function executeCompact(
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncation Skipped",
|
||||
message: "No tool outputs found to truncate.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let skipSummarize = false;
|
||||
|
||||
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||
const largest = findLargestToolResult(sessionID);
|
||||
|
||||
if (
|
||||
largest &&
|
||||
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
|
||||
) {
|
||||
const result = truncateToolResult(largest.partPath);
|
||||
|
||||
if (result.success) {
|
||||
truncateState.truncateAttempt++;
|
||||
truncateState.lastTruncatedPartId = largest.partId;
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncating Large Output",
|
||||
message: `Truncated ${result.toolName} (${formatBytes(result.originalSize ?? 0)}). Retrying...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
} else if (
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
skipSummarize = true;
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Summarize Skipped",
|
||||
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (!errorData?.currentTokens) {
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Truncation Skipped",
|
||||
message: "No large tool outputs found.",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
// Truncation was insufficient - fall through to Summarize
|
||||
log("[auto-compact] truncation insufficient, falling through to summarize", {
|
||||
sessionID,
|
||||
truncatedCount: aggressiveResult.truncatedCount,
|
||||
sufficient: aggressiveResult.sufficient,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||
|
||||
if (errorData?.errorType?.includes("non-empty content")) {
|
||||
@@ -581,7 +475,7 @@ export async function executeCompact(
|
||||
autoCompactState.truncateStateBySession.delete(sessionID);
|
||||
}
|
||||
|
||||
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
|
||||
retryState.attempt++;
|
||||
retryState.lastAttemptTime = Date.now();
|
||||
|
||||
|
||||
@@ -26,9 +26,23 @@ const TOKEN_LIMIT_KEYWORDS = [
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
// Patterns that indicate thinking block structure errors (NOT token limit errors)
|
||||
// These should be handled by session-recovery hook, not compaction
|
||||
const THINKING_BLOCK_ERROR_PATTERNS = [
|
||||
/thinking.*first block/i,
|
||||
/first block.*thinking/i,
|
||||
/must.*start.*thinking/i,
|
||||
/thinking.*redacted_thinking/i,
|
||||
/expected.*thinking.*found/i,
|
||||
/thinking.*disabled.*cannot.*contain/i,
|
||||
]
|
||||
|
||||
function isThinkingBlockError(text: string): boolean {
|
||||
return THINKING_BLOCK_ERROR_PATTERNS.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
@@ -52,6 +66,9 @@ function extractMessageIndex(text: string): number | undefined {
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
if (isThinkingBlockError(text)) {
|
||||
return false
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, test, expect, mock, beforeEach } from "bun:test"
|
||||
import { truncateUntilTargetTokens } from "./storage"
|
||||
import * as storage from "./storage"
|
||||
|
||||
// Mock the entire module
|
||||
mock.module("./storage", () => {
|
||||
return {
|
||||
...storage,
|
||||
findToolResultsBySize: mock(() => []),
|
||||
truncateToolResult: mock(() => ({ success: false })),
|
||||
}
|
||||
})
|
||||
|
||||
describe("truncateUntilTargetTokens", () => {
|
||||
const sessionID = "test-session"
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
findToolResultsBySize.mockReset()
|
||||
truncateToolResult.mockReset()
|
||||
})
|
||||
|
||||
test("truncates only until target is reached", () => {
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
|
||||
// #given: Two tool results, each 1000 chars. Target reduction is 500 chars.
|
||||
const results = [
|
||||
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
|
||||
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
|
||||
]
|
||||
|
||||
findToolResultsBySize.mockReturnValue(results)
|
||||
truncateToolResult.mockImplementation((path: string) => ({
|
||||
success: true,
|
||||
toolName: path === "path1" ? "tool1" : "tool2",
|
||||
originalSize: 1000
|
||||
}))
|
||||
|
||||
// #when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
|
||||
// charsPerToken=1 for simplicity in test
|
||||
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
|
||||
// #then: Should only truncate the first tool
|
||||
expect(result.truncatedCount).toBe(1)
|
||||
expect(truncateToolResult).toHaveBeenCalledTimes(1)
|
||||
expect(truncateToolResult).toHaveBeenCalledWith("path1")
|
||||
expect(result.totalBytesRemoved).toBe(1000)
|
||||
expect(result.sufficient).toBe(true)
|
||||
})
|
||||
|
||||
test("truncates all if target not reached", () => {
|
||||
const { findToolResultsBySize, truncateToolResult } = require("./storage")
|
||||
|
||||
// #given: Two tool results, each 100 chars. Target reduction is 500 chars.
|
||||
const results = [
|
||||
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
|
||||
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
|
||||
]
|
||||
|
||||
findToolResultsBySize.mockReturnValue(results)
|
||||
truncateToolResult.mockImplementation((path: string) => ({
|
||||
success: true,
|
||||
toolName: path === "path1" ? "tool1" : "tool2",
|
||||
originalSize: 100
|
||||
}))
|
||||
|
||||
// #when: reduce 500 chars
|
||||
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
|
||||
|
||||
// #then: Should truncate both
|
||||
expect(result.truncatedCount).toBe(2)
|
||||
expect(truncateToolResult).toHaveBeenCalledTimes(2)
|
||||
expect(result.totalBytesRemoved).toBe(200)
|
||||
expect(result.sufficient).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -230,6 +230,10 @@ export function truncateUntilTargetTokens(
|
||||
toolName: truncateResult.toolName ?? result.toolName,
|
||||
originalSize: removedSize,
|
||||
})
|
||||
|
||||
if (totalRemoved >= charsToReduce) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
11
src/hooks/auto-slash-command/constants.ts
Normal file
11
src/hooks/auto-slash-command/constants.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export const HOOK_NAME = "auto-slash-command" as const
|
||||
|
||||
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
|
||||
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
|
||||
|
||||
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
|
||||
|
||||
export const EXCLUDED_COMMANDS = new Set([
|
||||
"ralph-loop",
|
||||
"cancel-ralph",
|
||||
])
|
||||
296
src/hooks/auto-slash-command/detector.test.ts
Normal file
296
src/hooks/auto-slash-command/detector.test.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
parseSlashCommand,
|
||||
detectSlashCommand,
|
||||
isExcludedCommand,
|
||||
removeCodeBlocks,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
|
||||
describe("auto-slash-command detector", () => {
|
||||
describe("removeCodeBlocks", () => {
|
||||
it("should remove markdown code blocks", () => {
|
||||
// #given text with code blocks
|
||||
const text = "Hello ```code here``` world"
|
||||
|
||||
// #when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then code blocks should be removed
|
||||
expect(result).toBe("Hello world")
|
||||
})
|
||||
|
||||
it("should remove multiline code blocks", () => {
|
||||
// #given text with multiline code blocks
|
||||
const text = `Before
|
||||
\`\`\`javascript
|
||||
/command-inside-code
|
||||
\`\`\`
|
||||
After`
|
||||
|
||||
// #when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then code blocks should be removed
|
||||
expect(result).toContain("Before")
|
||||
expect(result).toContain("After")
|
||||
expect(result).not.toContain("/command-inside-code")
|
||||
})
|
||||
|
||||
it("should handle text without code blocks", () => {
|
||||
// #given text without code blocks
|
||||
const text = "Just regular text"
|
||||
|
||||
// #when removing code blocks
|
||||
const result = removeCodeBlocks(text)
|
||||
|
||||
// #then text should remain unchanged
|
||||
expect(result).toBe("Just regular text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSlashCommand", () => {
|
||||
it("should parse simple command without args", () => {
|
||||
// #given a simple slash command
|
||||
const text = "/commit"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command correctly
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
expect(result?.args).toBe("")
|
||||
})
|
||||
|
||||
it("should parse command with arguments", () => {
|
||||
// #given a slash command with arguments
|
||||
const text = "/plan create a new feature for auth"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command and args
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("plan")
|
||||
expect(result?.args).toBe("create a new feature for auth")
|
||||
})
|
||||
|
||||
it("should parse command with quoted arguments", () => {
|
||||
// #given a slash command with quoted arguments
|
||||
const text = '/execute "build the API"'
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract command and args
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("execute")
|
||||
expect(result?.args).toBe('"build the API"')
|
||||
})
|
||||
|
||||
it("should parse command with hyphen in name", () => {
|
||||
// #given a slash command with hyphen
|
||||
const text = "/frontend-template-creator project"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should extract full command name
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("frontend-template-creator")
|
||||
expect(result?.args).toBe("project")
|
||||
})
|
||||
|
||||
it("should return null for non-slash text", () => {
|
||||
// #given text without slash
|
||||
const text = "regular text"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash not at start", () => {
|
||||
// #given text with slash in middle
|
||||
const text = "some text /command"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null (slash not at start)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for just a slash", () => {
|
||||
// #given just a slash
|
||||
const text = "/"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for slash followed by number", () => {
|
||||
// #given slash followed by number
|
||||
const text = "/123"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should return null (command must start with letter)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should handle whitespace before slash", () => {
|
||||
// #given command with leading whitespace
|
||||
const text = " /commit"
|
||||
|
||||
// #when parsing
|
||||
const result = parseSlashCommand(text)
|
||||
|
||||
// #then should parse after trimming
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isExcludedCommand", () => {
|
||||
it("should exclude ralph-loop", () => {
|
||||
// #given ralph-loop command
|
||||
// #when checking exclusion
|
||||
// #then should be excluded
|
||||
expect(isExcludedCommand("ralph-loop")).toBe(true)
|
||||
})
|
||||
|
||||
it("should exclude cancel-ralph", () => {
|
||||
// #given cancel-ralph command
|
||||
// #when checking exclusion
|
||||
// #then should be excluded
|
||||
expect(isExcludedCommand("cancel-ralph")).toBe(true)
|
||||
})
|
||||
|
||||
it("should be case-insensitive for exclusion", () => {
|
||||
// #given uppercase variants
|
||||
// #when checking exclusion
|
||||
// #then should still be excluded
|
||||
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
|
||||
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
|
||||
})
|
||||
|
||||
it("should not exclude regular commands", () => {
|
||||
// #given regular commands
|
||||
// #when checking exclusion
|
||||
// #then should not be excluded
|
||||
expect(isExcludedCommand("commit")).toBe(false)
|
||||
expect(isExcludedCommand("plan")).toBe(false)
|
||||
expect(isExcludedCommand("execute")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectSlashCommand", () => {
|
||||
it("should detect slash command in plain text", () => {
|
||||
// #given plain text with slash command
|
||||
const text = "/commit fix typo"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should detect
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
expect(result?.args).toBe("fix typo")
|
||||
})
|
||||
|
||||
it("should NOT detect slash command inside code block", () => {
|
||||
// #given slash command inside code block
|
||||
const text = "```bash\n/command\n```"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should not detect (only code block content)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should detect command when text has code blocks elsewhere", () => {
|
||||
// #given slash command before code block
|
||||
const text = "/commit fix\n```code```"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should detect the command
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.command).toBe("commit")
|
||||
})
|
||||
|
||||
it("should NOT detect excluded commands", () => {
|
||||
// #given excluded command
|
||||
const text = "/ralph-loop do something"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should not detect
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for non-command text", () => {
|
||||
// #given regular text
|
||||
const text = "Just some regular text"
|
||||
|
||||
// #when detecting
|
||||
const result = detectSlashCommand(text)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("extractPromptText", () => {
|
||||
it("should extract text from parts", () => {
|
||||
// #given message parts
|
||||
const parts = [
|
||||
{ type: "text", text: "Hello " },
|
||||
{ type: "tool_use", id: "123" },
|
||||
{ type: "text", text: "world" },
|
||||
]
|
||||
|
||||
// #when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should join text parts
|
||||
expect(result).toBe("Hello world")
|
||||
})
|
||||
|
||||
it("should handle empty parts", () => {
|
||||
// #given empty parts
|
||||
const parts: Array<{ type: string; text?: string }> = []
|
||||
|
||||
// #when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
|
||||
it("should handle parts without text", () => {
|
||||
// #given parts without text content
|
||||
const parts = [
|
||||
{ type: "tool_use", id: "123" },
|
||||
{ type: "tool_result", output: "result" },
|
||||
]
|
||||
|
||||
// #when extracting
|
||||
const result = extractPromptText(parts)
|
||||
|
||||
// #then should return empty string
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
65
src/hooks/auto-slash-command/detector.ts
Normal file
65
src/hooks/auto-slash-command/detector.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
SLASH_COMMAND_PATTERN,
|
||||
EXCLUDED_COMMANDS,
|
||||
} from "./constants"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "")
|
||||
}
|
||||
|
||||
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
const trimmed = text.trim()
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = trimmed.match(SLASH_COMMAND_PATTERN)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [raw, command, args] = match
|
||||
return {
|
||||
command: command.toLowerCase(),
|
||||
args: args.trim(),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
export function isExcludedCommand(command: string): boolean {
|
||||
return EXCLUDED_COMMANDS.has(command.toLowerCase())
|
||||
}
|
||||
|
||||
export function detectSlashCommand(text: string): ParsedSlashCommand | null {
|
||||
const textWithoutCodeBlocks = removeCodeBlocks(text)
|
||||
const trimmed = textWithoutCodeBlocks.trim()
|
||||
|
||||
if (!trimmed.startsWith("/")) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parsed = parseSlashCommand(trimmed)
|
||||
|
||||
if (!parsed) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isExcludedCommand(parsed.command)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join(" ")
|
||||
}
|
||||
194
src/hooks/auto-slash-command/executor.ts
Normal file
194
src/hooks/auto-slash-command/executor.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { homedir } from "os"
|
||||
import {
|
||||
parseFrontmatter,
|
||||
resolveCommandsInText,
|
||||
resolveFileReferencesInText,
|
||||
sanitizeModelField,
|
||||
getClaudeConfigDir,
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
interface CommandScope {
|
||||
type: "user" | "project" | "opencode" | "opencode-project" | "skill"
|
||||
}
|
||||
|
||||
interface CommandMetadata {
|
||||
name: string
|
||||
description: string
|
||||
argumentHint?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
interface CommandInfo {
|
||||
name: string
|
||||
path?: string
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope["type"]
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: CommandInfo[] = []
|
||||
|
||||
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 isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
name: commandName,
|
||||
description: data.description || "",
|
||||
argumentHint: data["argument-hint"],
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: Boolean(data.subtask),
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: body,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
return {
|
||||
name: skill.name,
|
||||
path: skill.path,
|
||||
metadata: {
|
||||
name: skill.name,
|
||||
description: skill.definition.description || "",
|
||||
argumentHint: skill.definition.argumentHint,
|
||||
model: skill.definition.model,
|
||||
agent: skill.definition.agent,
|
||||
subtask: skill.definition.subtask,
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: "skill",
|
||||
}
|
||||
}
|
||||
|
||||
function discoverAllCommands(): CommandInfo[] {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "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")
|
||||
|
||||
const skills = discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
...opencodeProjectCommands,
|
||||
...projectCommands,
|
||||
...opencodeGlobalCommands,
|
||||
...userCommands,
|
||||
...skillCommands,
|
||||
]
|
||||
}
|
||||
|
||||
function findCommand(commandName: string): CommandInfo | null {
|
||||
const allCommands = discoverAllCommands()
|
||||
return allCommands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
|
||||
) ?? null
|
||||
}
|
||||
|
||||
async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<string> {
|
||||
const sections: string[] = []
|
||||
|
||||
sections.push(`# /${cmd.name} Command\n`)
|
||||
|
||||
if (cmd.metadata.description) {
|
||||
sections.push(`**Description**: ${cmd.metadata.description}\n`)
|
||||
}
|
||||
|
||||
if (args) {
|
||||
sections.push(`**User Arguments**: ${args}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.model) {
|
||||
sections.push(`**Model**: ${cmd.metadata.model}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.agent) {
|
||||
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
|
||||
}
|
||||
|
||||
sections.push(`**Scope**: ${cmd.scope}\n`)
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
if (args) {
|
||||
sections.push("\n\n---\n")
|
||||
sections.push("## User Request\n")
|
||||
sections.push(args)
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export interface ExecuteResult {
|
||||
success: boolean
|
||||
replacementText?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
|
||||
const command = findCommand(parsed.command)
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const template = await formatCommandTemplate(command, parsed.args)
|
||||
return {
|
||||
success: true,
|
||||
replacementText: template,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to load command "/${parsed.command}": ${err instanceof Error ? err.message : String(err)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
258
src/hooks/auto-slash-command/index.test.ts
Normal file
258
src/hooks/auto-slash-command/index.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
|
||||
// Import real shared module to avoid mock leaking to other test files
|
||||
import * as shared from "../../shared"
|
||||
|
||||
// Spy on log instead of mocking the entire module
|
||||
const logMock = spyOn(shared, "log").mockImplementation(() => {})
|
||||
|
||||
|
||||
|
||||
const { createAutoSlashCommandHook } = await import("./index")
|
||||
|
||||
function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput {
|
||||
return {
|
||||
sessionID,
|
||||
messageID: messageID ?? `msg-${Date.now()}-${Math.random()}`,
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||
}
|
||||
}
|
||||
|
||||
function createMockOutput(text: string): AutoSlashCommandHookOutput {
|
||||
return {
|
||||
message: {
|
||||
agent: "test-agent",
|
||||
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
|
||||
path: { cwd: "/test", root: "/test" },
|
||||
tools: {},
|
||||
},
|
||||
parts: [{ type: "text", text }],
|
||||
}
|
||||
}
|
||||
|
||||
describe("createAutoSlashCommandHook", () => {
|
||||
beforeEach(() => {
|
||||
logMock.mockClear()
|
||||
})
|
||||
|
||||
describe("slash command replacement", () => {
|
||||
it("should replace message with error when command not found", async () => {
|
||||
// #given a slash command that doesn't exist
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-notfound-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/nonexistent-command args")
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should replace with error message
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).toContain("<auto-slash-command>")
|
||||
expect(textPart?.text).toContain("not found")
|
||||
})
|
||||
|
||||
it("should wrap replacement in auto-slash-command tags", async () => {
|
||||
// #given any slash command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-tags-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/some-command")
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should wrap in tags
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).toContain("<auto-slash-command>")
|
||||
expect(textPart?.text).toContain("</auto-slash-command>")
|
||||
})
|
||||
|
||||
it("should completely replace original message text", async () => {
|
||||
// #given slash command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-replace-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/test-cmd some args")
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then original text should be replaced, not prepended
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).not.toContain("/test-cmd some args\n<auto-slash-command>")
|
||||
expect(textPart?.text?.startsWith("<auto-slash-command>")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("no slash command", () => {
|
||||
it("should do nothing for regular text", async () => {
|
||||
// #given regular text without slash
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-regular-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("Just regular text")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should do nothing for slash in middle of text", async () => {
|
||||
// #given slash in middle
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-middle-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("Please run /commit later")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not detect (not at start)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("excluded commands", () => {
|
||||
it("should NOT trigger for ralph-loop command", async () => {
|
||||
// #given ralph-loop command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-ralph-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/ralph-loop do something")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify (excluded command)
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should NOT trigger for cancel-ralph command", async () => {
|
||||
// #given cancel-ralph command
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-cancel-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/cancel-ralph")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("already processed", () => {
|
||||
it("should skip if auto-slash-command tags already present", async () => {
|
||||
// #given text with existing tags
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-existing-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput(
|
||||
"<auto-slash-command>/commit</auto-slash-command>"
|
||||
)
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("code blocks", () => {
|
||||
it("should NOT detect command inside code block", async () => {
|
||||
// #given command inside code block
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-codeblock-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("```\n/commit\n```")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not detect
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
})
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle empty text", async () => {
|
||||
// #given empty text
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-empty-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("")
|
||||
|
||||
// #when hook is called
|
||||
// #then should not throw
|
||||
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle just slash", async () => {
|
||||
// #given just slash
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-slash-only-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput("/")
|
||||
const originalText = output.parts[0].text
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should not modify
|
||||
expect(output.parts[0].text).toBe(originalText)
|
||||
})
|
||||
|
||||
it("should handle command with special characters in args", async () => {
|
||||
// #given command with special characters
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-special-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output = createMockOutput('/execute "test & stuff <tag>"')
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should handle gracefully (not found, but processed)
|
||||
const textPart = output.parts.find((p) => p.type === "text")
|
||||
expect(textPart?.text).toContain("<auto-slash-command>")
|
||||
expect(textPart?.text).toContain("/execute")
|
||||
})
|
||||
|
||||
it("should handle multiple text parts", async () => {
|
||||
// #given multiple text parts
|
||||
const hook = createAutoSlashCommandHook()
|
||||
const sessionID = `test-session-multi-${Date.now()}`
|
||||
const input = createMockInput(sessionID)
|
||||
const output: AutoSlashCommandHookOutput = {
|
||||
message: {},
|
||||
parts: [
|
||||
{ type: "text", text: "/commit " },
|
||||
{ type: "text", text: "fix bug" },
|
||||
],
|
||||
}
|
||||
|
||||
// #when hook is called
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then should detect from combined text and modify first text part
|
||||
const firstTextPart = output.parts.find((p) => p.type === "text")
|
||||
expect(firstTextPart?.text).toContain("<auto-slash-command>")
|
||||
})
|
||||
})
|
||||
})
|
||||
82
src/hooks/auto-slash-command/index.ts
Normal file
82
src/hooks/auto-slash-command/index.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
|
||||
export function createAutoSlashCommandHook() {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
output: AutoSlashCommandHookOutput
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
if (
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = detectSlashCommand(promptText)
|
||||
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
sessionProcessedCommands.add(commandKey)
|
||||
|
||||
log(`[auto-slash-command] Detected: /${parsed.command}`, {
|
||||
sessionID: input.sessionID,
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed)
|
||||
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.success && result.replacementText) {
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = taggedContent
|
||||
|
||||
log(`[auto-slash-command] Replaced message with command template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
})
|
||||
} else {
|
||||
const errorMessage = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n[AUTO-SLASH-COMMAND ERROR]\n${result.error}\n\nOriginal input: ${parsed.raw}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = errorMessage
|
||||
|
||||
log(`[auto-slash-command] Command not found, showing error`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
error: result.error,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
23
src/hooks/auto-slash-command/types.ts
Normal file
23
src/hooks/auto-slash-command/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export interface AutoSlashCommandHookInput {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandHookOutput {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
export interface ParsedSlashCommand {
|
||||
command: string
|
||||
args: string
|
||||
raw: string
|
||||
}
|
||||
|
||||
export interface AutoSlashCommandResult {
|
||||
detected: boolean
|
||||
parsedCommand?: ParsedSlashCommand
|
||||
injectedMessage?: string
|
||||
}
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
INSTALLED_PACKAGE_JSON,
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
USER_CONFIG_DIR,
|
||||
getWindowsAppdataDir,
|
||||
} from "./constants"
|
||||
import * as os from "node:os"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export function isLocalDevMode(directory: string): boolean {
|
||||
@@ -23,12 +26,32 @@ function stripJsonComments(json: string): string {
|
||||
}
|
||||
|
||||
function getConfigPaths(directory: string): string[] {
|
||||
return [
|
||||
const paths = [
|
||||
path.join(directory, ".opencode", "opencode.json"),
|
||||
path.join(directory, ".opencode", "opencode.jsonc"),
|
||||
USER_OPENCODE_CONFIG,
|
||||
USER_OPENCODE_CONFIG_JSONC,
|
||||
]
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = getWindowsAppdataDir()
|
||||
|
||||
if (appdataDir) {
|
||||
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
|
||||
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
|
||||
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (!paths.includes(alternateConfig)) {
|
||||
paths.push(alternateConfig)
|
||||
}
|
||||
if (!paths.includes(alternateConfigJsonc)) {
|
||||
paths.push(alternateConfigJsonc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
export function getLocalDevPath(directory: string): string | null {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as path from "node:path"
|
||||
import * as os from "node:os"
|
||||
import * as fs from "node:fs"
|
||||
|
||||
export const PACKAGE_NAME = "oh-my-opencode"
|
||||
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
|
||||
@@ -28,14 +29,36 @@ export const INSTALLED_PACKAGE_JSON = path.join(
|
||||
|
||||
/**
|
||||
* OpenCode config file locations (priority order)
|
||||
* On Windows, checks ~/.config first (cross-platform), then %APPDATA% (fallback)
|
||||
* This matches shared/config-path.ts behavior for consistency
|
||||
*/
|
||||
function getUserConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
const crossPlatformDir = path.join(os.homedir(), ".config")
|
||||
const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
|
||||
// Check cross-platform path first (~/.config)
|
||||
const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
|
||||
const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
|
||||
|
||||
if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
|
||||
return crossPlatformDir
|
||||
}
|
||||
|
||||
// Fall back to %APPDATA%
|
||||
return appdataDir
|
||||
}
|
||||
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Windows-specific APPDATA directory (for fallback checks)
|
||||
*/
|
||||
export function getWindowsAppdataDir(): string | null {
|
||||
if (process.platform !== "win32") return null
|
||||
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
|
||||
}
|
||||
|
||||
export const USER_CONFIG_DIR = getUserConfigDir()
|
||||
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
|
||||
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")
|
||||
|
||||
@@ -4,6 +4,7 @@ import { invalidatePackage } from "./cache"
|
||||
import { PACKAGE_NAME } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors"
|
||||
import { runBunInstall } from "../../cli/config-manager"
|
||||
import type { AutoUpdateCheckerOptions } from "./types"
|
||||
|
||||
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
|
||||
@@ -100,16 +101,34 @@ async function runBackgroundUpdateCheck(
|
||||
|
||||
if (pluginInfo.isPinned) {
|
||||
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
|
||||
if (updated) {
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
|
||||
} else {
|
||||
if (!updated) {
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] Failed to update pinned version in config")
|
||||
return
|
||||
}
|
||||
log(`[auto-update-checker] Config updated: ${pluginInfo.entry} → ${PACKAGE_NAME}@${latestVersion}`)
|
||||
}
|
||||
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
|
||||
const installSuccess = await runBunInstallSafe()
|
||||
|
||||
if (installSuccess) {
|
||||
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
|
||||
log(`[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`)
|
||||
} else {
|
||||
invalidatePackage(PACKAGE_NAME)
|
||||
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
|
||||
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
|
||||
}
|
||||
}
|
||||
|
||||
async function runBunInstallSafe(): Promise<boolean> {
|
||||
try {
|
||||
return await runBunInstall()
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
log("[auto-update-checker] bun install error:", errorMessage)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,11 +112,6 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
if (isFirstMessage) {
|
||||
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "UserPromptSubmit")) {
|
||||
const userPromptCtx: UserPromptSubmitContext = {
|
||||
sessionId: input.sessionID,
|
||||
@@ -144,24 +139,33 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length })
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
|
||||
log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID })
|
||||
}
|
||||
} else {
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -60,12 +60,17 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
const isRootDir = current === ctx.directory;
|
||||
if (!isRootDir) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
if (isRootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
|
||||
@@ -23,3 +23,4 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
|
||||
@@ -4,7 +4,7 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
|
||||
// ULTRAWORK: ulw, ultrawork
|
||||
{
|
||||
pattern: /\b(ultrawork|ulw)\b/i,
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: `<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
INLINE_CODE_PATTERN,
|
||||
} from "./constants"
|
||||
|
||||
export interface DetectedKeyword {
|
||||
type: "ultrawork" | "search" | "analyze"
|
||||
message: string
|
||||
}
|
||||
|
||||
export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
|
||||
}
|
||||
@@ -15,6 +20,18 @@ export function detectKeywords(text: string): string[] {
|
||||
).map(({ message }) => message)
|
||||
}
|
||||
|
||||
export function detectKeywordsWithType(text: string): DetectedKeyword[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
|
||||
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
|
||||
matches: pattern.test(textWithoutCode),
|
||||
type: types[index],
|
||||
message,
|
||||
}))
|
||||
.filter((result) => result.matches)
|
||||
.map(({ type, message }) => ({ type, message }))
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { detectKeywords, extractPromptText } from "./detector"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
|
||||
@@ -7,8 +8,9 @@ export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionUltraworkNotified = new Set<string>()
|
||||
|
||||
export function createKeywordDetectorHook() {
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
@@ -26,12 +28,28 @@ export function createKeywordDetectorHook() {
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const messages = detectKeywords(promptText)
|
||||
const detectedKeywords = detectKeywordsWithType(promptText)
|
||||
const messages = detectedKeywords.map((k) => k.message)
|
||||
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork && !sessionUltraworkNotified.has(input.sessionID)) {
|
||||
sessionUltraworkNotified.add(input.sessionID)
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
|
||||
}
|
||||
|
||||
const context = messages.join("\n")
|
||||
|
||||
// First message: transform parts directly (for title generation compatibility)
|
||||
|
||||
@@ -7,10 +7,11 @@ export const NON_INTERACTIVE_ENV: Record<string, string> = {
|
||||
GCM_INTERACTIVE: "never",
|
||||
HOMEBREW_NO_AUTO_UPDATE: "1",
|
||||
// Block interactive editors - git rebase, commit, etc.
|
||||
GIT_EDITOR: "true",
|
||||
EDITOR: "true",
|
||||
VISUAL: "true",
|
||||
GIT_SEQUENCE_EDITOR: "true",
|
||||
GIT_EDITOR: ":",
|
||||
EDITOR: ":",
|
||||
VISUAL: "",
|
||||
GIT_SEQUENCE_EDITOR: ":",
|
||||
GIT_MERGE_AUTOEDIT: "no",
|
||||
// Block pagers
|
||||
GIT_PAGER: "cat",
|
||||
PAGER: "cat",
|
||||
|
||||
@@ -35,6 +35,7 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
}
|
||||
|
||||
output.args.env = {
|
||||
...process.env,
|
||||
...(output.args.env as Record<string, string> | undefined),
|
||||
...NON_INTERACTIVE_ENV,
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ describe("ralph-loop", () => {
|
||||
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
|
||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||
let messagesCalls: Array<{ sessionID: string }>
|
||||
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
@@ -22,6 +24,10 @@ describe("ralph-loop", () => {
|
||||
})
|
||||
return {}
|
||||
},
|
||||
messages: async (opts: { path: { id: string } }) => {
|
||||
messagesCalls.push({ sessionID: opts.path.id })
|
||||
return { data: mockSessionMessages }
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
||||
@@ -35,12 +41,14 @@ describe("ralph-loop", () => {
|
||||
},
|
||||
},
|
||||
directory: TEST_DIR,
|
||||
} as Parameters<typeof createRalphLoopHook>[0]
|
||||
} as unknown as Parameters<typeof createRalphLoopHook>[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
messagesCalls = []
|
||||
mockSessionMessages = []
|
||||
|
||||
if (!existsSync(TEST_DIR)) {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
@@ -351,6 +359,35 @@ describe("ralph-loop", () => {
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should detect completion promise via session messages API", async () => {
|
||||
// #given - active loop with assistant message containing completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I have completed the task. <promise>API_DONE</promise>" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "API_DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop completed via API detection, no continuation
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
|
||||
// #then - messages API was called with correct session ID
|
||||
expect(messagesCalls.length).toBe(1)
|
||||
expect(messagesCalls[0].sessionID).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should handle multiple iterations correctly", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
@@ -18,6 +18,17 @@ interface SessionState {
|
||||
isRecovering?: boolean
|
||||
}
|
||||
|
||||
interface OpenCodeSessionMessage {
|
||||
info?: {
|
||||
role?: string
|
||||
}
|
||||
parts?: Array<{
|
||||
type: string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}>
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
|
||||
|
||||
Your previous attempt did not output the completion promise. Continue working on the task.
|
||||
@@ -81,6 +92,41 @@ export function createRalphLoopHook(
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
async function detectCompletionInSessionMessages(
|
||||
sessionID: string,
|
||||
promise: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
const messages = (response as { data?: unknown[] }).data ?? []
|
||||
|
||||
if (!Array.isArray(messages)) return false
|
||||
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
|
||||
for (const msg of messages as OpenCodeSessionMessage[]) {
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
|
||||
for (const part of msg.parts || []) {
|
||||
if (part.type === "text" && part.text) {
|
||||
if (pattern.test(part.text)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const startLoop = (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
@@ -151,14 +197,20 @@ export function createRalphLoopHook(
|
||||
return
|
||||
}
|
||||
|
||||
// Generate transcript path from sessionID - OpenCode doesn't pass it in event properties
|
||||
const transcriptPath = getTranscriptPath(sessionID)
|
||||
const completionDetectedViaApi = await detectCompletionInSessionMessages(
|
||||
sessionID,
|
||||
state.completion_promise
|
||||
)
|
||||
|
||||
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
|
||||
const transcriptPath = getTranscriptPath(sessionID)
|
||||
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
|
||||
|
||||
if (completionDetectedViaApi || completionDetectedViaTranscript) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
|
||||
@@ -14,10 +14,17 @@ export const PROJECT_MARKERS = [
|
||||
];
|
||||
|
||||
export const PROJECT_RULE_SUBDIRS: [string, string][] = [
|
||||
[".github", "instructions"],
|
||||
[".cursor", "rules"],
|
||||
[".claude", "rules"],
|
||||
];
|
||||
|
||||
export const PROJECT_RULE_FILES: string[] = [
|
||||
".github/copilot-instructions.md",
|
||||
];
|
||||
|
||||
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
|
||||
|
||||
export const USER_RULE_DIR = ".claude/rules";
|
||||
|
||||
export const RULE_EXTENSIONS = [".md", ".mdc"];
|
||||
|
||||
381
src/hooks/rules-injector/finder.test.ts
Normal file
381
src/hooks/rules-injector/finder.test.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
|
||||
describe("findRuleFiles", () => {
|
||||
const TEST_DIR = join(tmpdir(), `rules-injector-test-${Date.now()}`);
|
||||
const homeDir = join(TEST_DIR, "home");
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
mkdirSync(homeDir, { recursive: true });
|
||||
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe(".github/instructions/ discovery", () => {
|
||||
it("should discover .github/instructions/*.instructions.md files", () => {
|
||||
// #given .github/instructions/ with valid files
|
||||
const instructionsDir = join(TEST_DIR, ".github", "instructions");
|
||||
mkdirSync(instructionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(instructionsDir, "typescript.instructions.md"),
|
||||
"TS rules"
|
||||
);
|
||||
writeFileSync(
|
||||
join(instructionsDir, "python.instructions.md"),
|
||||
"PY rules"
|
||||
);
|
||||
|
||||
const srcDir = join(TEST_DIR, "src");
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
const currentFile = join(srcDir, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules for a file
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find both instruction files
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(
|
||||
paths.some((p) => p.includes("typescript.instructions.md"))
|
||||
).toBe(true);
|
||||
expect(paths.some((p) => p.includes("python.instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should ignore non-.instructions.md files in .github/instructions/", () => {
|
||||
// #given .github/instructions/ with invalid files
|
||||
const instructionsDir = join(TEST_DIR, ".github", "instructions");
|
||||
mkdirSync(instructionsDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(instructionsDir, "valid.instructions.md"),
|
||||
"valid"
|
||||
);
|
||||
writeFileSync(join(instructionsDir, "invalid.md"), "invalid");
|
||||
writeFileSync(join(instructionsDir, "readme.txt"), "readme");
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should only find .instructions.md file
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes("valid.instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(paths.some((p) => p.endsWith("invalid.md"))).toBe(false);
|
||||
expect(paths.some((p) => p.includes("readme.txt"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should discover nested .instructions.md files in subdirectories", () => {
|
||||
// #given nested .github/instructions/ structure
|
||||
const instructionsDir = join(TEST_DIR, ".github", "instructions");
|
||||
const frontendDir = join(instructionsDir, "frontend");
|
||||
mkdirSync(frontendDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(frontendDir, "react.instructions.md"),
|
||||
"React rules"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.tsx");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find nested instruction file
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes("react.instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe(".github/copilot-instructions.md (single file)", () => {
|
||||
it("should discover copilot-instructions.md at project root", () => {
|
||||
// #given .github/copilot-instructions.md at root
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Global instructions"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find the single file rule
|
||||
const singleFile = candidates.find((c) =>
|
||||
c.path.includes("copilot-instructions.md")
|
||||
);
|
||||
expect(singleFile).toBeDefined();
|
||||
expect(singleFile?.isSingleFile).toBe(true);
|
||||
});
|
||||
|
||||
it("should mark single file rules with isSingleFile: true", () => {
|
||||
// #given copilot-instructions.md
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Instructions"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "file.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then isSingleFile should be true
|
||||
const copilotFile = candidates.find((c) => c.isSingleFile);
|
||||
expect(copilotFile).toBeDefined();
|
||||
expect(copilotFile?.path).toContain("copilot-instructions.md");
|
||||
});
|
||||
|
||||
it("should set distance to 0 for single file rules", () => {
|
||||
// #given copilot-instructions.md at project root
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Instructions"
|
||||
);
|
||||
|
||||
const srcDir = join(TEST_DIR, "src", "deep", "nested");
|
||||
mkdirSync(srcDir, { recursive: true });
|
||||
const currentFile = join(srcDir, "file.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules from deeply nested file
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then single file should have distance 0
|
||||
const copilotFile = candidates.find((c) => c.isSingleFile);
|
||||
expect(copilotFile?.distance).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("backward compatibility", () => {
|
||||
it("should still discover .claude/rules/ files", () => {
|
||||
// #given .claude/rules/ directory
|
||||
const rulesDir = join(TEST_DIR, ".claude", "rules");
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
writeFileSync(join(rulesDir, "typescript.md"), "TS rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find claude rules
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should still discover .cursor/rules/ files", () => {
|
||||
// #given .cursor/rules/ directory
|
||||
const rulesDir = join(TEST_DIR, ".cursor", "rules");
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
writeFileSync(join(rulesDir, "python.md"), "PY rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "main.py");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find cursor rules
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("should discover .mdc files in rule directories", () => {
|
||||
// #given .mdc file in .claude/rules/
|
||||
const rulesDir = join(TEST_DIR, ".claude", "rules");
|
||||
mkdirSync(rulesDir, { recursive: true });
|
||||
writeFileSync(join(rulesDir, "advanced.mdc"), "MDC rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find .mdc file
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.endsWith("advanced.mdc"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed sources", () => {
|
||||
it("should discover rules from all sources", () => {
|
||||
// #given rules in multiple directories
|
||||
const claudeRules = join(TEST_DIR, ".claude", "rules");
|
||||
const cursorRules = join(TEST_DIR, ".cursor", "rules");
|
||||
const githubInstructions = join(TEST_DIR, ".github", "instructions");
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
|
||||
mkdirSync(claudeRules, { recursive: true });
|
||||
mkdirSync(cursorRules, { recursive: true });
|
||||
mkdirSync(githubInstructions, { recursive: true });
|
||||
|
||||
writeFileSync(join(claudeRules, "claude.md"), "claude");
|
||||
writeFileSync(join(cursorRules, "cursor.md"), "cursor");
|
||||
writeFileSync(
|
||||
join(githubInstructions, "copilot.instructions.md"),
|
||||
"copilot"
|
||||
);
|
||||
writeFileSync(join(githubDir, "copilot-instructions.md"), "global");
|
||||
|
||||
const currentFile = join(TEST_DIR, "index.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find all rules
|
||||
expect(candidates.length).toBeGreaterThanOrEqual(4);
|
||||
const paths = candidates.map((c) => c.path);
|
||||
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
|
||||
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
|
||||
expect(paths.some((p) => p.includes(".github/instructions/"))).toBe(
|
||||
true
|
||||
);
|
||||
expect(paths.some((p) => p.includes("copilot-instructions.md"))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should not duplicate single file rules", () => {
|
||||
// #given copilot-instructions.md
|
||||
const githubDir = join(TEST_DIR, ".github");
|
||||
mkdirSync(githubDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(githubDir, "copilot-instructions.md"),
|
||||
"Instructions"
|
||||
);
|
||||
|
||||
const currentFile = join(TEST_DIR, "file.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should only have one copilot-instructions.md entry
|
||||
const copilotFiles = candidates.filter((c) =>
|
||||
c.path.includes("copilot-instructions.md")
|
||||
);
|
||||
expect(copilotFiles.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("user-level rules", () => {
|
||||
it("should discover user-level .claude/rules/ files", () => {
|
||||
// #given user-level rules
|
||||
const userRulesDir = join(homeDir, ".claude", "rules");
|
||||
mkdirSync(userRulesDir, { recursive: true });
|
||||
writeFileSync(join(userRulesDir, "global.md"), "Global user rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then should find user-level rules
|
||||
const userRule = candidates.find((c) => c.isGlobal);
|
||||
expect(userRule).toBeDefined();
|
||||
expect(userRule?.path).toContain("global.md");
|
||||
});
|
||||
|
||||
it("should mark user-level rules as isGlobal: true", () => {
|
||||
// #given user-level rules
|
||||
const userRulesDir = join(homeDir, ".claude", "rules");
|
||||
mkdirSync(userRulesDir, { recursive: true });
|
||||
writeFileSync(join(userRulesDir, "user.md"), "User rules");
|
||||
|
||||
const currentFile = join(TEST_DIR, "app.ts");
|
||||
writeFileSync(currentFile, "code");
|
||||
|
||||
// #when finding rules
|
||||
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
|
||||
|
||||
// #then isGlobal should be true
|
||||
const userRule = candidates.find((c) => c.path.includes("user.md"));
|
||||
expect(userRule?.isGlobal).toBe(true);
|
||||
expect(userRule?.distance).toBe(9999);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findProjectRoot", () => {
|
||||
const TEST_DIR = join(tmpdir(), `project-root-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("should find project root with .git directory", () => {
|
||||
// #given directory with .git
|
||||
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
|
||||
const nestedFile = join(TEST_DIR, "src", "components", "Button.tsx");
|
||||
mkdirSync(join(TEST_DIR, "src", "components"), { recursive: true });
|
||||
writeFileSync(nestedFile, "code");
|
||||
|
||||
// #when finding project root from nested file
|
||||
const root = findProjectRoot(nestedFile);
|
||||
|
||||
// #then should return the directory with .git
|
||||
expect(root).toBe(TEST_DIR);
|
||||
});
|
||||
|
||||
it("should find project root with package.json", () => {
|
||||
// #given directory with package.json
|
||||
writeFileSync(join(TEST_DIR, "package.json"), "{}");
|
||||
const nestedFile = join(TEST_DIR, "lib", "index.js");
|
||||
mkdirSync(join(TEST_DIR, "lib"), { recursive: true });
|
||||
writeFileSync(nestedFile, "code");
|
||||
|
||||
// #when finding project root
|
||||
const root = findProjectRoot(nestedFile);
|
||||
|
||||
// #then should find the package.json directory
|
||||
expect(root).toBe(TEST_DIR);
|
||||
});
|
||||
|
||||
it("should return null when no project markers found", () => {
|
||||
// #given directory without any project markers
|
||||
const isolatedDir = join(TEST_DIR, "isolated");
|
||||
mkdirSync(isolatedDir, { recursive: true });
|
||||
const file = join(isolatedDir, "file.txt");
|
||||
writeFileSync(file, "content");
|
||||
|
||||
// #when finding project root
|
||||
const root = findProjectRoot(file);
|
||||
|
||||
// #then should return null
|
||||
expect(root).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,24 +6,24 @@ import {
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import {
|
||||
GITHUB_INSTRUCTIONS_PATTERN,
|
||||
PROJECT_MARKERS,
|
||||
PROJECT_RULE_FILES,
|
||||
PROJECT_RULE_SUBDIRS,
|
||||
RULE_EXTENSIONS,
|
||||
USER_RULE_DIR,
|
||||
} from "./constants";
|
||||
import type { RuleFileCandidate } from "./types";
|
||||
|
||||
/**
|
||||
* Candidate rule file with metadata for filtering and sorting
|
||||
*/
|
||||
export interface RuleFileCandidate {
|
||||
/** Absolute path to the rule file */
|
||||
path: string;
|
||||
/** Real path after symlink resolution (for duplicate detection) */
|
||||
realPath: string;
|
||||
/** Whether this is a global/user-level rule */
|
||||
isGlobal: boolean;
|
||||
/** Directory distance from current file (9999 for global rules) */
|
||||
distance: number;
|
||||
function isGitHubInstructionsDir(dir: string): boolean {
|
||||
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
|
||||
}
|
||||
|
||||
function isValidRuleFile(fileName: string, dir: string): boolean {
|
||||
if (isGitHubInstructionsDir(dir)) {
|
||||
return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
|
||||
}
|
||||
return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,10 +76,7 @@ function findRuleFilesRecursive(dir: string, results: string[]): void {
|
||||
if (entry.isDirectory()) {
|
||||
findRuleFilesRecursive(fullPath, results);
|
||||
} else if (entry.isFile()) {
|
||||
const isRuleFile = RULE_EXTENSIONS.some((ext) =>
|
||||
entry.name.endsWith(ext),
|
||||
);
|
||||
if (isRuleFile) {
|
||||
if (isValidRuleFile(entry.name, dir)) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
@@ -133,8 +130,10 @@ export function calculateDistance(
|
||||
return 9999;
|
||||
}
|
||||
|
||||
const ruleParts = ruleRel ? ruleRel.split("/") : [];
|
||||
const currentParts = currentRel ? currentRel.split("/") : [];
|
||||
// Split by both forward and back slashes for cross-platform compatibility
|
||||
// path.relative() returns OS-native separators (backslashes on Windows)
|
||||
const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : [];
|
||||
const currentParts = currentRel ? currentRel.split(/[/\\]/) : [];
|
||||
|
||||
// Find common prefix length
|
||||
let common = 0;
|
||||
@@ -207,6 +206,33 @@ export function findRuleFiles(
|
||||
distance++;
|
||||
}
|
||||
|
||||
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
|
||||
if (projectRoot) {
|
||||
for (const ruleFile of PROJECT_RULE_FILES) {
|
||||
const filePath = join(projectRoot, ruleFile);
|
||||
if (existsSync(filePath)) {
|
||||
try {
|
||||
const stat = statSync(filePath);
|
||||
if (stat.isFile()) {
|
||||
const realPath = safeRealpathSync(filePath);
|
||||
if (!seenRealPaths.has(realPath)) {
|
||||
seenRealPaths.add(realPath);
|
||||
candidates.push({
|
||||
path: filePath,
|
||||
realPath,
|
||||
isGlobal: false,
|
||||
distance: 0,
|
||||
isSingleFile: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip if file can't be read
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Search user-level rule directory (~/.claude/rules)
|
||||
const userRuleDir = join(homeDir, USER_RULE_DIR);
|
||||
const userFiles: string[] = [];
|
||||
|
||||
@@ -100,8 +100,14 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
@@ -112,7 +118,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason: matchResult.reason ?? "matched",
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
226
src/hooks/rules-injector/parser.test.ts
Normal file
226
src/hooks/rules-injector/parser.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
|
||||
describe("parseRuleFrontmatter", () => {
|
||||
describe("applyTo field (GitHub Copilot format)", () => {
|
||||
it("should parse applyTo as single string", () => {
|
||||
// #given frontmatter with applyTo as single string
|
||||
const content = `---
|
||||
applyTo: "*.ts"
|
||||
---
|
||||
Rule content here`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should contain the pattern
|
||||
expect(result.metadata.globs).toBe("*.ts");
|
||||
expect(result.body).toBe("Rule content here");
|
||||
});
|
||||
|
||||
it("should parse applyTo as inline array", () => {
|
||||
// #given frontmatter with applyTo as inline array
|
||||
const content = `---
|
||||
applyTo: ["*.ts", "*.tsx"]
|
||||
---
|
||||
Rule content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should be array
|
||||
expect(result.metadata.globs).toEqual(["*.ts", "*.tsx"]);
|
||||
});
|
||||
|
||||
it("should parse applyTo as multi-line array", () => {
|
||||
// #given frontmatter with applyTo as multi-line array
|
||||
const content = `---
|
||||
applyTo:
|
||||
- "*.ts"
|
||||
- "src/**/*.js"
|
||||
---
|
||||
Content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should be array
|
||||
expect(result.metadata.globs).toEqual(["*.ts", "src/**/*.js"]);
|
||||
});
|
||||
|
||||
it("should parse applyTo as comma-separated string", () => {
|
||||
// #given frontmatter with comma-separated applyTo
|
||||
const content = `---
|
||||
applyTo: "*.ts, *.js"
|
||||
---
|
||||
Content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then globs should be array
|
||||
expect(result.metadata.globs).toEqual(["*.ts", "*.js"]);
|
||||
});
|
||||
|
||||
it("should merge applyTo and globs when both present", () => {
|
||||
// #given frontmatter with both applyTo and globs
|
||||
const content = `---
|
||||
globs: "*.md"
|
||||
applyTo: "*.ts"
|
||||
---
|
||||
Content`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should merge both into globs array
|
||||
expect(result.metadata.globs).toEqual(["*.md", "*.ts"]);
|
||||
});
|
||||
|
||||
it("should parse applyTo without quotes", () => {
|
||||
// #given frontmatter with unquoted applyTo
|
||||
const content = `---
|
||||
applyTo: **/*.py
|
||||
---
|
||||
Python rules`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse correctly
|
||||
expect(result.metadata.globs).toBe("**/*.py");
|
||||
});
|
||||
|
||||
it("should parse applyTo with description", () => {
|
||||
// #given frontmatter with applyTo and description (GitHub Copilot style)
|
||||
const content = `---
|
||||
applyTo: "**/*.ts,**/*.tsx"
|
||||
description: "TypeScript coding standards"
|
||||
---
|
||||
# TypeScript Guidelines`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse both fields
|
||||
expect(result.metadata.globs).toEqual(["**/*.ts", "**/*.tsx"]);
|
||||
expect(result.metadata.description).toBe("TypeScript coding standards");
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing globs/paths parsing (backward compatibility)", () => {
|
||||
it("should still parse globs field correctly", () => {
|
||||
// #given existing globs format
|
||||
const content = `---
|
||||
globs: ["*.py", "**/*.ts"]
|
||||
---
|
||||
Python/TypeScript rules`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should work as before
|
||||
expect(result.metadata.globs).toEqual(["*.py", "**/*.ts"]);
|
||||
});
|
||||
|
||||
it("should still parse paths field as alias", () => {
|
||||
// #given paths field (Claude Code style)
|
||||
const content = `---
|
||||
paths: ["src/**"]
|
||||
---
|
||||
Source rules`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should map to globs
|
||||
expect(result.metadata.globs).toEqual(["src/**"]);
|
||||
});
|
||||
|
||||
it("should parse alwaysApply correctly", () => {
|
||||
// #given frontmatter with alwaysApply
|
||||
const content = `---
|
||||
alwaysApply: true
|
||||
---
|
||||
Always apply this rule`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should recognize alwaysApply
|
||||
expect(result.metadata.alwaysApply).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no frontmatter", () => {
|
||||
it("should return empty metadata and full body for plain markdown", () => {
|
||||
// #given markdown without frontmatter
|
||||
const content = `# Instructions
|
||||
This is a plain rule file without frontmatter.`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should have empty metadata
|
||||
expect(result.metadata).toEqual({});
|
||||
expect(result.body).toBe(content);
|
||||
});
|
||||
|
||||
it("should handle empty content", () => {
|
||||
// #given empty content
|
||||
const content = "";
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should return empty metadata and body
|
||||
expect(result.metadata).toEqual({});
|
||||
expect(result.body).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle frontmatter with only applyTo", () => {
|
||||
// #given minimal GitHub Copilot format
|
||||
const content = `---
|
||||
applyTo: "**"
|
||||
---
|
||||
Apply to all files`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse correctly
|
||||
expect(result.metadata.globs).toBe("**");
|
||||
expect(result.body).toBe("Apply to all files");
|
||||
});
|
||||
|
||||
it("should handle mixed array formats", () => {
|
||||
// #given globs as multi-line and applyTo as inline
|
||||
const content = `---
|
||||
globs:
|
||||
- "*.md"
|
||||
applyTo: ["*.ts", "*.js"]
|
||||
---
|
||||
Mixed format`;
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should merge both
|
||||
expect(result.metadata.globs).toEqual(["*.md", "*.ts", "*.js"]);
|
||||
});
|
||||
|
||||
it("should handle Windows-style line endings", () => {
|
||||
// #given content with CRLF
|
||||
const content = "---\r\napplyTo: \"*.ts\"\r\n---\r\nWindows content";
|
||||
|
||||
// #when parsing
|
||||
const result = parseRuleFrontmatter(content);
|
||||
|
||||
// #then should parse correctly
|
||||
expect(result.metadata.globs).toBe("*.ts");
|
||||
expect(result.body).toBe("Windows content");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -60,7 +60,7 @@ function parseYamlContent(yamlContent: string): RuleMetadata {
|
||||
metadata.description = parseStringValue(rawValue);
|
||||
} else if (key === "alwaysApply") {
|
||||
metadata.alwaysApply = rawValue === "true";
|
||||
} else if (key === "globs" || key === "paths") {
|
||||
} else if (key === "globs" || key === "paths" || key === "applyTo") {
|
||||
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
|
||||
// Merge paths into globs (Claude Code compatibility)
|
||||
if (key === "paths") {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Rule file metadata (Claude Code style frontmatter)
|
||||
* Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo)
|
||||
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
|
||||
* @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
|
||||
*/
|
||||
export interface RuleMetadata {
|
||||
description?: string;
|
||||
@@ -30,6 +32,18 @@ export interface RuleInfo {
|
||||
realPath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rule file candidate with discovery context
|
||||
*/
|
||||
export interface RuleFileCandidate {
|
||||
path: string;
|
||||
realPath: string;
|
||||
isGlobal: boolean;
|
||||
distance: number;
|
||||
/** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */
|
||||
isSingleFile?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session storage for injected rules tracking
|
||||
*/
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
import { describe, expect, it, beforeEach, mock } from "bun:test"
|
||||
import { describe, expect, it, beforeEach } from "bun:test"
|
||||
import type { ThinkModeInput } from "./types"
|
||||
|
||||
const logMock = mock(() => {})
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
|
||||
const { createThinkModeHook, clearThinkModeState } = await import("./index")
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
@@ -20,6 +20,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
@@ -41,8 +42,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
return {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
}
|
||||
@@ -164,42 +165,42 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
|
||||
test("should skip injection after recent error", async () => {
|
||||
// #given - session that just had an error
|
||||
test("should skip injection when abort error occurs immediately before idle", async () => {
|
||||
// #given - session that just had an abort error
|
||||
const sessionID = "main-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session error occurs
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
|
||||
event: { type: "session.error", properties: { sessionID, error: { name: "AbortError", message: "aborted" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
// #when - session goes idle immediately after abort
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (error cooldown)
|
||||
// #then - no continuation injected (abort was immediately before idle)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should clear error state on user message and allow injection", async () => {
|
||||
// #given - session with error, then user clears it
|
||||
test("should clear abort state on user message and allow injection", async () => {
|
||||
// #given - session with abort error, then user clears it
|
||||
const sessionID = "main-error-clear"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - error occurs
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID } },
|
||||
event: { type: "session.error", properties: { sessionID, error: { message: "aborted" } } },
|
||||
})
|
||||
|
||||
// #when - user sends message (clears error immediately)
|
||||
// #when - user sends message (clears abort state)
|
||||
await hook.handler({
|
||||
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
|
||||
})
|
||||
@@ -211,7 +212,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (error was cleared by user message)
|
||||
// #then - continuation injected (abort state was cleared by user message)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
@@ -229,9 +230,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -255,9 +256,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -401,4 +402,211 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
}, { timeout: 10000 })
|
||||
|
||||
// ============================================================
|
||||
// ABORT "IMMEDIATELY BEFORE" DETECTION TESTS
|
||||
// These tests verify that abort errors only block continuation
|
||||
// when they occur IMMEDIATELY before session.idle, not based
|
||||
// on a time-based cooldown.
|
||||
// ============================================================
|
||||
|
||||
test("should skip injection ONLY when abort error occurs immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-immediate"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs (with abort-specific error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle IMMEDIATELY after abort (no other events in between)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (abort was immediately before idle)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject normally when abort error is followed by assistant activity before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-then-assistant"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - assistant sends a message (intervening event clears abort state)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle (abort is no longer "immediately before")
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort was NOT immediately before idle)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject normally when abort error is followed by tool execution before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-then-tool"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { message: "aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - tool execution happens (intervening event)
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.after", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort was NOT immediately before idle)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-noabort-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - non-abort error occurs (e.g., network error, API error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "NetworkError", message: "Connection failed" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (non-abort errors don't block)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject after abort if time passes and new idle event occurs", async () => {
|
||||
// #given - session with incomplete todos, abort happened previously
|
||||
const sessionID = "main-abort-time-passed"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "AbortError", message: "cancelled" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - first idle (immediately after abort) - should be skipped
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
|
||||
// #when - second idle event occurs (abort is no longer "immediately before")
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected on second idle (abort state was consumed)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("should handle multiple abort errors correctly - only last one matters", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-multi-abort"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - first abort error
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "aborted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - second abort error (immediately before idle)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "interrupted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - idle immediately after second abort
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort was immediately before)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../features/hook-message-injector"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
@@ -29,7 +29,7 @@ interface Todo {
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastErrorAt?: number
|
||||
lastEventWasAbortError?: boolean
|
||||
countdownTimer?: ReturnType<typeof setTimeout>
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
@@ -45,7 +45,6 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
||||
|
||||
const COUNTDOWN_SECONDS = 2
|
||||
const TOAST_DURATION_MS = 900
|
||||
const ERROR_COOLDOWN_MS = 3_000
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
@@ -63,22 +62,22 @@ function getMessageDir(sessionID: string): string | null {
|
||||
|
||||
function isAbortError(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)?.toLowerCase() ?? ""
|
||||
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -105,7 +104,7 @@ export function createTodoContinuationEnforcer(
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
|
||||
|
||||
if (state.countdownTimer) {
|
||||
clearTimeout(state.countdownTimer)
|
||||
state.countdownTimer = undefined
|
||||
@@ -149,16 +148,13 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
|
||||
if (state?.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
@@ -187,9 +183,9 @@ export function createTodoContinuationEnforcer(
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
|
||||
|
||||
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
return
|
||||
@@ -203,18 +199,23 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
model: modelField,
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
|
||||
|
||||
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
|
||||
@@ -251,10 +252,11 @@ export function createTodoContinuationEnforcer(
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
state.lastErrorAt = Date.now()
|
||||
const isAbort = isAbortError(props?.error)
|
||||
state.lastEventWasAbortError = isAbort
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -267,7 +269,7 @@ export function createTodoContinuationEnforcer(
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isMainSession = sessionID === mainSessionID
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
|
||||
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
|
||||
return
|
||||
@@ -280,8 +282,9 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
|
||||
log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
|
||||
if (state.lastEventWasAbortError) {
|
||||
state.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -325,13 +328,14 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastErrorAt = undefined
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
|
||||
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
@@ -346,6 +350,10 @@ export function createTodoContinuationEnforcer(
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -354,6 +362,10 @@ export function createTodoContinuationEnforcer(
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
|
||||
@@ -16,6 +16,9 @@ const TRUNCATABLE_TOOLS = [
|
||||
"ast_grep_search",
|
||||
"interactive_bash",
|
||||
"Interactive_bash",
|
||||
"skill_mcp",
|
||||
"webfetch",
|
||||
"WebFetch",
|
||||
]
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
|
||||
500
src/index.ts
500
src/index.ts
@@ -1,5 +1,4 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { createBuiltinAgents } from "./agents";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
@@ -25,20 +24,10 @@ import {
|
||||
createEmptyMessageSanitizerHook,
|
||||
createThinkingBlockValidatorHook,
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
} from "./hooks";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "./features/claude-code-command-loader";
|
||||
import { loadBuiltinCommands } from "./features/builtin-commands";
|
||||
import {
|
||||
loadUserSkills,
|
||||
loadProjectSkills,
|
||||
loadOpencodeGlobalSkills,
|
||||
loadOpencodeProjectSkills,
|
||||
discoverUserClaudeSkills,
|
||||
discoverProjectClaudeSkills,
|
||||
discoverOpencodeGlobalSkills,
|
||||
@@ -46,138 +35,35 @@ import {
|
||||
mergeSkills,
|
||||
} from "./features/opencode-skill-loader";
|
||||
import { createBuiltinSkills } from "./features/builtin-skills";
|
||||
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "./features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
|
||||
import { loadAllPluginComponents } from "./features/claude-code-plugin-loader";
|
||||
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
|
||||
import {
|
||||
setMainSession,
|
||||
getMainSessionID,
|
||||
} from "./features/claude-code-session-state";
|
||||
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, interactive_bash, getTmuxPath } from "./tools";
|
||||
import {
|
||||
builtinTools,
|
||||
createCallOmoAgent,
|
||||
createBackgroundTools,
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
interactive_bash,
|
||||
getTmuxPath,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile, migrateConfigFile } from "./shared";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
||||
|
||||
migrateConfigFile(configPath, rawConfig);
|
||||
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", ");
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
addConfigLoadError({ path: configPath, error: `Validation error: ${errorMsg}` });
|
||||
return null;
|
||||
}
|
||||
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Error loading config from ${configPath}:`, err);
|
||||
addConfigLoadError({ path: configPath, error: errorMsg });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeConfigs(
|
||||
base: OhMyOpenCodeConfig,
|
||||
override: OhMyOpenCodeConfig
|
||||
): OhMyOpenCodeConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
...(override.disabled_agents ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_mcps: [
|
||||
...new Set([
|
||||
...(base.disabled_mcps ?? []),
|
||||
...(override.disabled_mcps ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_hooks: [
|
||||
...new Set([
|
||||
...(base.disabled_hooks ?? []),
|
||||
...(override.disabled_hooks ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_commands: [
|
||||
...new Set([
|
||||
...(base.disabled_commands ?? []),
|
||||
...(override.disabled_commands ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific) - prefer .jsonc over .json
|
||||
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
|
||||
const userDetected = detectConfigFile(userBasePath);
|
||||
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
|
||||
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||
const projectDetected = detectConfigFile(projectBasePath);
|
||||
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
log("Final merged config", {
|
||||
agents: config.agents,
|
||||
disabled_agents: config.disabled_agents,
|
||||
disabled_mcps: config.disabled_mcps,
|
||||
disabled_hooks: config.disabled_hooks,
|
||||
claude_code: config.claude_code,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { log } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
|
||||
|
||||
const modelContextLimitsCache = new Map<string, number>();
|
||||
let anthropicContext1MEnabled = false;
|
||||
|
||||
const getModelLimit = (providerID: string, modelID: string): number | undefined => {
|
||||
const key = `${providerID}/${modelID}`;
|
||||
const cached = modelContextLimitsCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
if (providerID === "anthropic" && anthropicContext1MEnabled && modelID.includes("sonnet")) {
|
||||
return 1_000_000;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
const modelCacheState = createModelCacheState();
|
||||
|
||||
const contextWindowMonitor = isHookEnabled("context-window-monitor")
|
||||
? createContextWindowMonitorHook(ctx)
|
||||
@@ -193,7 +79,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createCommentCheckerHooks(pluginConfig.comment_checker)
|
||||
: null;
|
||||
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
|
||||
? createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })
|
||||
? createToolOutputTruncatorHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
})
|
||||
: null;
|
||||
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
|
||||
? createDirectoryAgentsInjectorHook(ctx)
|
||||
@@ -204,13 +92,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector")
|
||||
? createEmptyTaskResponseDetectorHook(ctx)
|
||||
: null;
|
||||
const thinkMode = isHookEnabled("think-mode")
|
||||
? createThinkModeHook()
|
||||
: null;
|
||||
const thinkMode = isHookEnabled("think-mode") ? createThinkModeHook() : null;
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
|
||||
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
|
||||
});
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
|
||||
const anthropicContextWindowLimitRecovery = isHookEnabled(
|
||||
"anthropic-context-window-limit-recovery"
|
||||
)
|
||||
? createAnthropicContextWindowLimitRecoveryHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
|
||||
@@ -223,7 +111,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createPreemptiveCompactionHook(ctx, {
|
||||
experimental: pluginConfig.experimental,
|
||||
onBeforeSummarize: compactionContextInjector,
|
||||
getModelLimit,
|
||||
getModelLimit: (providerID, modelID) =>
|
||||
getModelLimit(modelCacheState, providerID, modelID),
|
||||
})
|
||||
: null;
|
||||
const rulesInjector = isHookEnabled("rules-injector")
|
||||
@@ -237,7 +126,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
})
|
||||
: null;
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? createKeywordDetectorHook()
|
||||
? createKeywordDetectorHook(ctx)
|
||||
: null;
|
||||
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
||||
? createAgentUsageReminderHook(ctx)
|
||||
@@ -259,6 +148,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
@@ -267,7 +160,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
if (sessionRecovery && todoContinuationEnforcer) {
|
||||
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
|
||||
sessionRecovery.setOnRecoveryCompleteCallback(
|
||||
todoContinuationEnforcer.markRecoveryComplete
|
||||
);
|
||||
}
|
||||
|
||||
const backgroundNotificationHook = isHookEnabled("background-notification")
|
||||
@@ -277,7 +172,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
|
||||
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
||||
const lookAt = createLookAt(ctx);
|
||||
const builtinSkills = createBuiltinSkills();
|
||||
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
||||
const systemMcpNames = getSystemMcpServerNames();
|
||||
const builtinSkills = createBuiltinSkills().filter((skill) => {
|
||||
if (disabledSkills.has(skill.name as never)) return false;
|
||||
if (skill.mcpConfig) {
|
||||
for (const mcpName of Object.keys(skill.mcpConfig)) {
|
||||
if (systemMcpNames.has(mcpName)) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
@@ -285,16 +190,34 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : [],
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverOpencodeProjectSkills()
|
||||
);
|
||||
const skillTool = createSkillTool({ skills: mergedSkills });
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
const skillTool = createSkillTool({
|
||||
skills: mergedSkills,
|
||||
mcpManager: skillMcpManager,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
const skillMcpTool = createSkillMcpTool({
|
||||
manager: skillMcpManager,
|
||||
getLoadedSkills: () => mergedSkills,
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
const googleAuthHooks =
|
||||
pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
const tmuxAvailable = await getTmuxPath();
|
||||
|
||||
const configHandler = createConfigHandler({
|
||||
ctx,
|
||||
pluginConfig,
|
||||
modelCacheState,
|
||||
});
|
||||
|
||||
return {
|
||||
...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}),
|
||||
|
||||
@@ -304,42 +227,64 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
call_omo_agent: callOmoAgent,
|
||||
look_at: lookAt,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
...(tmuxAvailable ? { interactive_bash } : {}),
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
|
||||
if (ralphLoop) {
|
||||
const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
|
||||
const promptText = parts
|
||||
?.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
.trim() || "";
|
||||
const parts = (
|
||||
output as { parts?: Array<{ type: string; text?: string }> }
|
||||
).parts;
|
||||
const promptText =
|
||||
parts
|
||||
?.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => p.text)
|
||||
.join("\n")
|
||||
.trim() || "";
|
||||
|
||||
const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") &&
|
||||
const isRalphLoopTemplate =
|
||||
promptText.includes("You are starting a Ralph Loop") &&
|
||||
promptText.includes("<user-task>");
|
||||
const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
|
||||
const isCancelRalphTemplate = promptText.includes(
|
||||
"Cancel the currently active Ralph Loop"
|
||||
);
|
||||
|
||||
if (isRalphLoopTemplate) {
|
||||
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i);
|
||||
const taskMatch = promptText.match(
|
||||
/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i
|
||||
);
|
||||
const rawTask = taskMatch?.[1]?.trim() || "";
|
||||
|
||||
|
||||
const quotedMatch = rawTask.match(/^["'](.+?)["']/);
|
||||
const prompt = quotedMatch?.[1] || rawTask.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
|
||||
const prompt =
|
||||
quotedMatch?.[1] ||
|
||||
rawTask.split(/\s+--/)[0]?.trim() ||
|
||||
"Complete the task as instructed";
|
||||
|
||||
const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i);
|
||||
const promiseMatch = rawTask.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
|
||||
const promiseMatch = rawTask.match(
|
||||
/--completion-promise=["']?([^"'\s]+)["']?/i
|
||||
);
|
||||
|
||||
log("[ralph-loop] Starting loop from chat.message", { sessionID: input.sessionID, prompt });
|
||||
log("[ralph-loop] Starting loop from chat.message", {
|
||||
sessionID: input.sessionID,
|
||||
prompt,
|
||||
});
|
||||
ralphLoop.startLoop(input.sessionID, prompt, {
|
||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
||||
maxIterations: maxIterMatch
|
||||
? parseInt(maxIterMatch[1], 10)
|
||||
: undefined,
|
||||
completionPromise: promiseMatch?.[1],
|
||||
});
|
||||
} else if (isCancelRalphTemplate) {
|
||||
log("[ralph-loop] Cancelling loop from chat.message", { sessionID: input.sessionID });
|
||||
log("[ralph-loop] Cancelling loop from chat.message", {
|
||||
sessionID: input.sessionID,
|
||||
});
|
||||
ralphLoop.cancelLoop(input.sessionID);
|
||||
}
|
||||
}
|
||||
@@ -349,207 +294,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
input: Record<string, never>,
|
||||
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
await thinkingBlockValidator?.[
|
||||
"experimental.chat.messages.transform"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
]?.(input, output as any);
|
||||
await emptyMessageSanitizer?.[
|
||||
"experimental.chat.messages.transform"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
]?.(input, output as any);
|
||||
},
|
||||
|
||||
config: async (config) => {
|
||||
type ProviderConfig = {
|
||||
options?: { headers?: Record<string, string> }
|
||||
models?: Record<string, { limit?: { context?: number } }>
|
||||
}
|
||||
const providers = config.provider as Record<string, ProviderConfig> | undefined;
|
||||
|
||||
const anthropicBeta = providers?.anthropic?.options?.headers?.["anthropic-beta"];
|
||||
anthropicContext1MEnabled = anthropicBeta?.includes("context-1m") ?? false;
|
||||
|
||||
if (providers) {
|
||||
for (const [providerID, providerConfig] of Object.entries(providers)) {
|
||||
const models = providerConfig?.models;
|
||||
if (models) {
|
||||
for (const [modelID, modelConfig] of Object.entries(models)) {
|
||||
const contextLimit = modelConfig?.limit?.context;
|
||||
if (contextLimit) {
|
||||
modelContextLimitsCache.set(`${providerID}/${modelID}`, contextLimit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
||||
? await loadAllPluginComponents({
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
: { commands: {}, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], plugins: [], errors: [] };
|
||||
|
||||
if (pluginComponents.plugins.length > 0) {
|
||||
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
||||
plugins: pluginComponents.plugins.map(p => `${p.name}@${p.version}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginComponents.errors.length > 0) {
|
||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||
}
|
||||
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
config.model,
|
||||
);
|
||||
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
|
||||
const pluginAgents = pluginComponents.agents;
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
// Set Sisyphus as default agent (feature added in OpenCode PR #5843)
|
||||
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
};
|
||||
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
|
||||
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
||||
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
||||
: openCodeBuilderBase;
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
|
||||
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const plannerSisyphusBase = {
|
||||
...planConfigWithoutName,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
color: config.agent?.plan?.color ?? "#6495ED",
|
||||
};
|
||||
|
||||
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
|
||||
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
|
||||
const filteredConfigAgents = config.agent ?
|
||||
Object.fromEntries(
|
||||
Object.entries(config.agent).filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
) : {};
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
|
||||
// Demote build/plan to subagent mode when replaced
|
||||
build: { ...config.agent?.build, mode: "subagent" },
|
||||
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...config.agent,
|
||||
};
|
||||
}
|
||||
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
};
|
||||
|
||||
if (config.agent.explore) {
|
||||
config.agent.explore.tools = {
|
||||
...config.agent.explore.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (config.agent.librarian) {
|
||||
config.agent.librarian.tools = {
|
||||
...config.agent.librarian.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (config.agent["multimodal-looker"]) {
|
||||
config.agent["multimodal-looker"].tools = {
|
||||
...config.agent["multimodal-looker"].tools,
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
};
|
||||
}
|
||||
|
||||
config.permission = {
|
||||
...config.permission,
|
||||
webfetch: "allow",
|
||||
external_directory: "allow",
|
||||
}
|
||||
|
||||
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
||||
? await loadMcpConfigs()
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = config.command ?? {};
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkills() : {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkills() : {};
|
||||
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
|
||||
const opencodeProjectSkills = loadOpencodeProjectSkills();
|
||||
|
||||
config.command = {
|
||||
...builtinCommands,
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...opencodeGlobalSkills,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
...opencodeProjectSkills,
|
||||
...pluginComponents.commands,
|
||||
...pluginComponents.skills,
|
||||
};
|
||||
},
|
||||
config: configHandler,
|
||||
|
||||
event: async (input) => {
|
||||
await autoUpdateChecker?.event(input);
|
||||
@@ -585,6 +340,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined);
|
||||
}
|
||||
if (sessionInfo?.id) {
|
||||
await skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
@@ -625,7 +383,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
if (input.tool === "task") {
|
||||
const args = output.args as Record<string, unknown>;
|
||||
const subagentType = args.subagent_type as string;
|
||||
const isExploreOrLibrarian = ["explore", "librarian"].includes(subagentType);
|
||||
const isExploreOrLibrarian = ["explore", "librarian"].includes(
|
||||
subagentType
|
||||
);
|
||||
|
||||
args.tools = {
|
||||
...(args.tools as Record<string, boolean> | undefined),
|
||||
@@ -640,15 +400,23 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const sessionID = input.sessionID || getMainSessionID();
|
||||
|
||||
if (command === "ralph-loop" && sessionID) {
|
||||
const rawArgs = args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
|
||||
const rawArgs =
|
||||
args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
|
||||
const taskMatch = rawArgs.match(/^["'](.+?)["']/);
|
||||
const prompt = taskMatch?.[1] || rawArgs.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
|
||||
const prompt =
|
||||
taskMatch?.[1] ||
|
||||
rawArgs.split(/\s+--/)[0]?.trim() ||
|
||||
"Complete the task as instructed";
|
||||
|
||||
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
|
||||
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
|
||||
const promiseMatch = rawArgs.match(
|
||||
/--completion-promise=["']?([^"'\s]+)["']?/i
|
||||
);
|
||||
|
||||
ralphLoop.startLoop(sessionID, prompt, {
|
||||
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
|
||||
maxIterations: maxIterMatch
|
||||
? parseInt(maxIterMatch[1], 10)
|
||||
: undefined,
|
||||
completionPromise: promiseMatch?.[1],
|
||||
});
|
||||
} else if (command === "cancel-ralph" && sessionID) {
|
||||
|
||||
134
src/plugin-config.ts
Normal file
134
src/plugin-config.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import {
|
||||
log,
|
||||
deepMerge,
|
||||
getUserConfigDir,
|
||||
addConfigLoadError,
|
||||
parseJsonc,
|
||||
detectConfigFile,
|
||||
migrateConfigFile,
|
||||
} from "./shared";
|
||||
|
||||
export function loadConfigFromPath(
|
||||
configPath: string,
|
||||
ctx: unknown
|
||||
): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content);
|
||||
|
||||
migrateConfigFile(configPath, rawConfig);
|
||||
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
const errorMsg = result.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join(", ");
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
addConfigLoadError({
|
||||
path: configPath,
|
||||
error: `Validation error: ${errorMsg}`,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
log(`Error loading config from ${configPath}:`, err);
|
||||
addConfigLoadError({ path: configPath, error: errorMsg });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function mergeConfigs(
|
||||
base: OhMyOpenCodeConfig,
|
||||
override: OhMyOpenCodeConfig
|
||||
): OhMyOpenCodeConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents: deepMerge(base.agents, override.agents),
|
||||
disabled_agents: [
|
||||
...new Set([
|
||||
...(base.disabled_agents ?? []),
|
||||
...(override.disabled_agents ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_mcps: [
|
||||
...new Set([
|
||||
...(base.disabled_mcps ?? []),
|
||||
...(override.disabled_mcps ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_hooks: [
|
||||
...new Set([
|
||||
...(base.disabled_hooks ?? []),
|
||||
...(override.disabled_hooks ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_commands: [
|
||||
...new Set([
|
||||
...(base.disabled_commands ?? []),
|
||||
...(override.disabled_commands ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_skills: [
|
||||
...new Set([
|
||||
...(base.disabled_skills ?? []),
|
||||
...(override.disabled_skills ?? []),
|
||||
]),
|
||||
],
|
||||
claude_code: deepMerge(base.claude_code, override.claude_code),
|
||||
};
|
||||
}
|
||||
|
||||
export function loadPluginConfig(
|
||||
directory: string,
|
||||
ctx: unknown
|
||||
): OhMyOpenCodeConfig {
|
||||
// User-level config path (OS-specific) - prefer .jsonc over .json
|
||||
const userBasePath = path.join(
|
||||
getUserConfigDir(),
|
||||
"opencode",
|
||||
"oh-my-opencode"
|
||||
);
|
||||
const userDetected = detectConfigFile(userBasePath);
|
||||
const userConfigPath =
|
||||
userDetected.format !== "none"
|
||||
? userDetected.path
|
||||
: userBasePath + ".json";
|
||||
|
||||
// Project-level config path - prefer .jsonc over .json
|
||||
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
|
||||
const projectDetected = detectConfigFile(projectBasePath);
|
||||
const projectConfigPath =
|
||||
projectDetected.format !== "none"
|
||||
? projectDetected.path
|
||||
: projectBasePath + ".json";
|
||||
|
||||
// Load user config first (base)
|
||||
let config: OhMyOpenCodeConfig =
|
||||
loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||
|
||||
// Override with project config
|
||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
}
|
||||
|
||||
log("Final merged config", {
|
||||
agents: config.agents,
|
||||
disabled_agents: config.disabled_agents,
|
||||
disabled_mcps: config.disabled_mcps,
|
||||
disabled_hooks: config.disabled_hooks,
|
||||
claude_code: config.claude_code,
|
||||
});
|
||||
return config;
|
||||
}
|
||||
280
src/plugin-handlers/config-handler.ts
Normal file
280
src/plugin-handlers/config-handler.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { createBuiltinAgents } from "../agents";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "../features/claude-code-command-loader";
|
||||
import { loadBuiltinCommands } from "../features/builtin-commands";
|
||||
import {
|
||||
loadUserSkills,
|
||||
loadProjectSkills,
|
||||
loadOpencodeGlobalSkills,
|
||||
loadOpencodeProjectSkills,
|
||||
} from "../features/opencode-skill-loader";
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "../features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "../features/claude-code-mcp-loader";
|
||||
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
||||
import { createBuiltinMcps } from "../mcp";
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import { log } from "../shared";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
|
||||
export interface ConfigHandlerDeps {
|
||||
ctx: { directory: string };
|
||||
pluginConfig: OhMyOpenCodeConfig;
|
||||
modelCacheState: ModelCacheState;
|
||||
}
|
||||
|
||||
export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
const { ctx, pluginConfig, modelCacheState } = deps;
|
||||
|
||||
return async (config: Record<string, unknown>) => {
|
||||
type ProviderConfig = {
|
||||
options?: { headers?: Record<string, string> };
|
||||
models?: Record<string, { limit?: { context?: number } }>;
|
||||
};
|
||||
const providers = config.provider as
|
||||
| Record<string, ProviderConfig>
|
||||
| undefined;
|
||||
|
||||
const anthropicBeta =
|
||||
providers?.anthropic?.options?.headers?.["anthropic-beta"];
|
||||
modelCacheState.anthropicContext1MEnabled =
|
||||
anthropicBeta?.includes("context-1m") ?? false;
|
||||
|
||||
if (providers) {
|
||||
for (const [providerID, providerConfig] of Object.entries(providers)) {
|
||||
const models = providerConfig?.models;
|
||||
if (models) {
|
||||
for (const [modelID, modelConfig] of Object.entries(models)) {
|
||||
const contextLimit = modelConfig?.limit?.context;
|
||||
if (contextLimit) {
|
||||
modelCacheState.modelContextLimitsCache.set(
|
||||
`${providerID}/${modelID}`,
|
||||
contextLimit
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
|
||||
? await loadAllPluginComponents({
|
||||
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
|
||||
})
|
||||
: {
|
||||
commands: {},
|
||||
skills: {},
|
||||
agents: {},
|
||||
mcpServers: {},
|
||||
hooksConfigs: [],
|
||||
plugins: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (pluginComponents.plugins.length > 0) {
|
||||
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
|
||||
plugins: pluginComponents.plugins.map((p) => `${p.name}@${p.version}`),
|
||||
});
|
||||
}
|
||||
|
||||
if (pluginComponents.errors.length > 0) {
|
||||
log(`Plugin load errors`, { errors: pluginComponents.errors });
|
||||
}
|
||||
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
ctx.directory,
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadUserAgents()
|
||||
: {};
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadProjectAgents()
|
||||
: {};
|
||||
const pluginAgents = pluginComponents.agents;
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled =
|
||||
pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
|
||||
const plannerEnabled =
|
||||
pluginConfig.sisyphus_agent?.planner_enabled ?? true;
|
||||
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
|
||||
|
||||
type AgentConfig = Record<
|
||||
string,
|
||||
Record<string, unknown> | undefined
|
||||
> & {
|
||||
build?: Record<string, unknown>;
|
||||
plan?: Record<string, unknown>;
|
||||
explore?: { tools?: Record<string, unknown> };
|
||||
librarian?: { tools?: Record<string, unknown> };
|
||||
"multimodal-looker"?: { tools?: Record<string, unknown> };
|
||||
};
|
||||
const configAgent = config.agent as AgentConfig | undefined;
|
||||
|
||||
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
|
||||
(config as { default_agent?: string }).default_agent = "Sisyphus";
|
||||
|
||||
const agentConfig: Record<string, unknown> = {
|
||||
Sisyphus: builtinAgents.Sisyphus,
|
||||
};
|
||||
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } =
|
||||
configAgent?.build ?? {};
|
||||
const openCodeBuilderOverride =
|
||||
pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
|
||||
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
|
||||
: openCodeBuilderBase;
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, ...planConfigWithoutName } =
|
||||
configAgent?.plan ?? {};
|
||||
const plannerSisyphusOverride =
|
||||
pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const plannerSisyphusBase = {
|
||||
...planConfigWithoutName,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
color: (configAgent?.plan?.color as string) ?? "#6495ED",
|
||||
};
|
||||
|
||||
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
|
||||
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
const filteredConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
Object.entries(configAgent).filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
: {};
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(
|
||||
Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")
|
||||
),
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents,
|
||||
build: { ...configAgent?.build, mode: "subagent" },
|
||||
...(replacePlan
|
||||
? { plan: { ...configAgent?.plan, mode: "subagent" } }
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...configAgent,
|
||||
};
|
||||
}
|
||||
|
||||
const agentResult = config.agent as AgentConfig;
|
||||
|
||||
config.tools = {
|
||||
...(config.tools as Record<string, unknown>),
|
||||
"grep_app_*": false,
|
||||
};
|
||||
|
||||
if (agentResult.explore) {
|
||||
agentResult.explore.tools = {
|
||||
...agentResult.explore.tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (agentResult.librarian) {
|
||||
agentResult.librarian.tools = {
|
||||
...agentResult.librarian.tools,
|
||||
call_omo_agent: false,
|
||||
"grep_app_*": true,
|
||||
};
|
||||
}
|
||||
if (agentResult["multimodal-looker"]) {
|
||||
agentResult["multimodal-looker"].tools = {
|
||||
...agentResult["multimodal-looker"].tools,
|
||||
task: false,
|
||||
call_omo_agent: false,
|
||||
look_at: false,
|
||||
};
|
||||
}
|
||||
|
||||
config.permission = {
|
||||
...(config.permission as Record<string, unknown>),
|
||||
webfetch: "allow",
|
||||
external_directory: "allow",
|
||||
};
|
||||
|
||||
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
|
||||
? await loadMcpConfigs()
|
||||
: { servers: {} };
|
||||
|
||||
config.mcp = {
|
||||
...(config.mcp as Record<string, unknown>),
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
...pluginComponents.mcpServers,
|
||||
};
|
||||
|
||||
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true)
|
||||
? loadUserCommands()
|
||||
: {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = (config.command as Record<string, unknown>) ?? {};
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true)
|
||||
? loadProjectCommands()
|
||||
: {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true)
|
||||
? loadUserSkills()
|
||||
: {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true)
|
||||
? loadProjectSkills()
|
||||
: {};
|
||||
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
|
||||
const opencodeProjectSkills = loadOpencodeProjectSkills();
|
||||
|
||||
config.command = {
|
||||
...builtinCommands,
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...opencodeGlobalSkills,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
...opencodeProjectSkills,
|
||||
...pluginComponents.commands,
|
||||
...pluginComponents.skills,
|
||||
};
|
||||
};
|
||||
}
|
||||
1
src/plugin-handlers/index.ts
Normal file
1
src/plugin-handlers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { createConfigHandler, type ConfigHandlerDeps } from "./config-handler";
|
||||
30
src/plugin-state.ts
Normal file
30
src/plugin-state.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export interface ModelCacheState {
|
||||
modelContextLimitsCache: Map<string, number>;
|
||||
anthropicContext1MEnabled: boolean;
|
||||
}
|
||||
|
||||
export function createModelCacheState(): ModelCacheState {
|
||||
return {
|
||||
modelContextLimitsCache: new Map<string, number>(),
|
||||
anthropicContext1MEnabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function getModelLimit(
|
||||
state: ModelCacheState,
|
||||
providerID: string,
|
||||
modelID: string
|
||||
): number | undefined {
|
||||
const key = `${providerID}/${modelID}`;
|
||||
const cached = state.modelContextLimitsCache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
if (
|
||||
providerID === "anthropic" &&
|
||||
state.anthropicContext1MEnabled &&
|
||||
modelID.includes("sonnet")
|
||||
) {
|
||||
return 1_000_000;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
63
src/shared/AGENTS.md
Normal file
63
src/shared/AGENTS.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# SHARED UTILITIES KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Cross-cutting utilities: path resolution, config management, text processing, Claude Code compatibility helpers.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── command-executor.ts # Shell exec with variable expansion
|
||||
├── config-errors.ts # Global error tracking
|
||||
├── config-path.ts # User/project config paths
|
||||
├── data-path.ts # XDG data directory
|
||||
├── deep-merge.ts # Type-safe recursive merge
|
||||
├── dynamic-truncator.ts # Token-aware truncation
|
||||
├── file-reference-resolver.ts # @filename syntax
|
||||
├── file-utils.ts # Symlink, markdown detection
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── hook-disabled.ts # Check if hook disabled
|
||||
├── jsonc-parser.ts # JSON with Comments
|
||||
├── logger.ts # File-based logging
|
||||
├── migration.ts # Legacy name compat (omo → Sisyphus)
|
||||
├── model-sanitizer.ts # Normalize model names
|
||||
├── pattern-matcher.ts # Tool name matching
|
||||
├── snake-case.ts # Case conversion
|
||||
└── tool-name.ts # PascalCase normalization
|
||||
```
|
||||
|
||||
## WHEN TO USE
|
||||
|
||||
| Task | Utility |
|
||||
|------|---------|
|
||||
| Find ~/.claude | `getClaudeConfigDir()` |
|
||||
| Merge configs | `deepMerge(base, override)` |
|
||||
| Parse user files | `parseJsonc()` |
|
||||
| Check hook enabled | `isHookDisabled(name, list)` |
|
||||
| Truncate output | `dynamicTruncate(text, budget)` |
|
||||
| Resolve @file | `resolveFileReferencesInText()` |
|
||||
| Execute shell | `resolveCommandsInText()` |
|
||||
| Legacy names | `migrateLegacyAgentNames()` |
|
||||
|
||||
## CRITICAL PATTERNS
|
||||
|
||||
```typescript
|
||||
// Dynamic truncation
|
||||
const output = dynamicTruncate(result, remainingTokens, 0.5)
|
||||
|
||||
// Deep merge priority
|
||||
const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
|
||||
|
||||
// Safe JSONC
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- Hardcoding paths (use getClaudeConfigDir, getUserConfigPath)
|
||||
- JSON.parse for user files (use parseJsonc)
|
||||
- Ignoring truncation (large outputs MUST use dynamicTruncate)
|
||||
- Direct string concat for configs (use deepMerge)
|
||||
262
src/shared/frontmatter.test.ts
Normal file
262
src/shared/frontmatter.test.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { parseFrontmatter } from "./frontmatter"
|
||||
|
||||
describe("parseFrontmatter", () => {
|
||||
// #region backward compatibility
|
||||
test("parses simple key-value frontmatter", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Test command
|
||||
agent: build
|
||||
---
|
||||
Body content`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Test command")
|
||||
expect(result.data.agent).toBe("build")
|
||||
expect(result.body).toBe("Body content")
|
||||
})
|
||||
|
||||
test("parses boolean values", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
subtask: true
|
||||
enabled: false
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<{ subtask: boolean; enabled: boolean }>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.subtask).toBe(true)
|
||||
expect(result.data.enabled).toBe(false)
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region complex YAML (handoffs support)
|
||||
test("parses complex array frontmatter (speckit handoffs)", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Execute planning workflow
|
||||
handoffs:
|
||||
- label: Create Tasks
|
||||
agent: speckit.tasks
|
||||
prompt: Break the plan into tasks
|
||||
send: true
|
||||
- label: Create Checklist
|
||||
agent: speckit.checklist
|
||||
prompt: Create a checklist
|
||||
---
|
||||
Workflow instructions`
|
||||
|
||||
interface TestMeta {
|
||||
description: string
|
||||
handoffs: Array<{ label: string; agent: string; prompt: string; send?: boolean }>
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<TestMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Execute planning workflow")
|
||||
expect(result.data.handoffs).toHaveLength(2)
|
||||
expect(result.data.handoffs[0].label).toBe("Create Tasks")
|
||||
expect(result.data.handoffs[0].agent).toBe("speckit.tasks")
|
||||
expect(result.data.handoffs[0].send).toBe(true)
|
||||
expect(result.data.handoffs[1].agent).toBe("speckit.checklist")
|
||||
expect(result.data.handoffs[1].send).toBeUndefined()
|
||||
})
|
||||
|
||||
test("parses nested objects in frontmatter", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
name: test
|
||||
config:
|
||||
timeout: 5000
|
||||
retry: true
|
||||
options:
|
||||
verbose: false
|
||||
---
|
||||
Content`
|
||||
|
||||
interface TestMeta {
|
||||
name: string
|
||||
config: {
|
||||
timeout: number
|
||||
retry: boolean
|
||||
options: { verbose: boolean }
|
||||
}
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<TestMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.name).toBe("test")
|
||||
expect(result.data.config.timeout).toBe(5000)
|
||||
expect(result.data.config.retry).toBe(true)
|
||||
expect(result.data.config.options.verbose).toBe(false)
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region edge cases
|
||||
test("handles content without frontmatter", () => {
|
||||
// #given
|
||||
const content = "Just body content"
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Just body content")
|
||||
})
|
||||
|
||||
test("handles empty frontmatter", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
|
||||
test("handles invalid YAML gracefully", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
invalid: yaml: syntax: here
|
||||
bad indentation
|
||||
---
|
||||
Body`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then - should not throw, return empty data
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
|
||||
test("handles frontmatter with only whitespace", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
|
||||
---
|
||||
Body with whitespace-only frontmatter`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter(content)
|
||||
|
||||
// #then
|
||||
expect(result.data).toEqual({})
|
||||
expect(result.body).toBe("Body with whitespace-only frontmatter")
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region mixed content
|
||||
test("preserves multiline body content", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
title: Test
|
||||
---
|
||||
Line 1
|
||||
Line 2
|
||||
|
||||
Line 4 after blank`
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<{ title: string }>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.title).toBe("Test")
|
||||
expect(result.body).toBe("Line 1\nLine 2\n\nLine 4 after blank")
|
||||
})
|
||||
|
||||
test("handles CRLF line endings", () => {
|
||||
// #given
|
||||
const content = "---\r\ndescription: Test\r\n---\r\nBody"
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<{ description: string }>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Test")
|
||||
expect(result.body).toBe("Body")
|
||||
})
|
||||
// #endregion
|
||||
|
||||
// #region extra fields tolerance
|
||||
test("allows extra fields beyond typed interface", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Test command
|
||||
agent: build
|
||||
extra_field: should not fail
|
||||
another_extra:
|
||||
nested: value
|
||||
array:
|
||||
- item1
|
||||
- item2
|
||||
custom_boolean: true
|
||||
custom_number: 42
|
||||
---
|
||||
Body content`
|
||||
|
||||
interface MinimalMeta {
|
||||
description: string
|
||||
agent: string
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<MinimalMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Test command")
|
||||
expect(result.data.agent).toBe("build")
|
||||
expect(result.body).toBe("Body content")
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.extra_field).toBe("should not fail")
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.another_extra).toEqual({ nested: "value", array: ["item1", "item2"] })
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.custom_boolean).toBe(true)
|
||||
// @ts-expect-error - accessing extra field not in MinimalMeta
|
||||
expect(result.data.custom_number).toBe(42)
|
||||
})
|
||||
|
||||
test("extra fields do not interfere with expected fields", () => {
|
||||
// #given
|
||||
const content = `---
|
||||
description: Original description
|
||||
unknown_field: extra value
|
||||
handoffs:
|
||||
- label: Task 1
|
||||
agent: test.agent
|
||||
---
|
||||
Content`
|
||||
|
||||
interface HandoffMeta {
|
||||
description: string
|
||||
handoffs: Array<{ label: string; agent: string }>
|
||||
}
|
||||
|
||||
// #when
|
||||
const result = parseFrontmatter<HandoffMeta>(content)
|
||||
|
||||
// #then
|
||||
expect(result.data.description).toBe("Original description")
|
||||
expect(result.data.handoffs).toHaveLength(1)
|
||||
expect(result.data.handoffs[0].label).toBe("Task 1")
|
||||
expect(result.data.handoffs[0].agent).toBe("test.agent")
|
||||
})
|
||||
// #endregion
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
export interface FrontmatterResult<T = Record<string, string>> {
|
||||
import yaml from "js-yaml"
|
||||
|
||||
export interface FrontmatterResult<T = Record<string, unknown>> {
|
||||
data: T
|
||||
body: string
|
||||
}
|
||||
|
||||
export function parseFrontmatter<T = Record<string, string>>(
|
||||
export function parseFrontmatter<T = Record<string, unknown>>(
|
||||
content: string
|
||||
): FrontmatterResult<T> {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
|
||||
const match = content.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
@@ -16,19 +18,12 @@ export function parseFrontmatter<T = Record<string, string>>(
|
||||
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
|
||||
}
|
||||
try {
|
||||
// Use JSON_SCHEMA for security - prevents code execution via YAML tags
|
||||
const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
|
||||
const data = (parsed ?? {}) as T
|
||||
return { data, body }
|
||||
} catch {
|
||||
return { data: {} as T, body }
|
||||
}
|
||||
|
||||
return { data: data as T, body }
|
||||
}
|
||||
|
||||
@@ -2,79 +2,67 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Custom tools extending agent capabilities: LSP integration (11 tools), AST-aware code search/replace, file operations with timeouts, background task management.
|
||||
Custom tools: 11 LSP tools, AST-aware search/replace, file ops with timeouts, background task management, session navigation.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
tools/
|
||||
├── ast-grep/ # AST-aware code search/replace (25 languages)
|
||||
│ ├── cli.ts # @ast-grep/cli subprocess
|
||||
│ ├── napi.ts # @ast-grep/napi native binding (preferred)
|
||||
│ ├── constants.ts, types.ts, tools.ts, utils.ts
|
||||
│ ├── napi.ts # @ast-grep/napi binding (preferred)
|
||||
│ └── cli.ts # @ast-grep/cli fallback
|
||||
├── background-task/ # Async agent task management
|
||||
├── call-omo-agent/ # Spawn explore/librarian agents
|
||||
├── glob/ # File pattern matching (timeout-safe)
|
||||
├── grep/ # Content search (timeout-safe)
|
||||
├── glob/ # File pattern matching (60s timeout)
|
||||
├── grep/ # Content search (60s timeout)
|
||||
├── interactive-bash/ # Tmux session management
|
||||
├── look-at/ # Multimodal analysis (PDF, images)
|
||||
├── lsp/ # 11 LSP tools
|
||||
├── lsp/ # 11 LSP tools (611 lines client.ts)
|
||||
│ ├── client.ts # LSP connection lifecycle
|
||||
│ ├── config.ts # Server configurations
|
||||
│ ├── tools.ts # Tool implementations
|
||||
│ └── types.ts
|
||||
├── session-manager/ # OpenCode session file management
|
||||
│ ├── constants.ts # Storage paths, descriptions
|
||||
│ ├── types.ts # Session data interfaces
|
||||
│ ├── storage.ts # File I/O operations
|
||||
│ ├── utils.ts # Formatting, filtering
|
||||
│ └── tools.ts # Tool implementations
|
||||
├── session-manager/ # OpenCode session file ops
|
||||
├── skill/ # Skill loading and execution
|
||||
├── skill-mcp/ # Skill-embedded MCP invocation
|
||||
├── slashcommand/ # Slash command execution
|
||||
└── index.ts # builtinTools export
|
||||
```
|
||||
|
||||
## TOOL CATEGORIES
|
||||
|
||||
| Category | Tools | Purpose |
|
||||
|----------|-------|---------|
|
||||
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve | IDE-like code intelligence |
|
||||
| AST | ast_grep_search, ast_grep_replace | Pattern-based code search/replace |
|
||||
| File Search | grep, glob | Content and file pattern matching |
|
||||
| Session | session_list, session_read, session_search, session_info | OpenCode session file management |
|
||||
| Background | background_task, background_output, background_cancel | Async agent orchestration |
|
||||
| Multimodal | look_at | PDF/image analysis via Gemini |
|
||||
| Terminal | interactive_bash | Tmux session control |
|
||||
| Commands | slashcommand | Execute slash commands |
|
||||
| Agents | call_omo_agent | Spawn explore/librarian |
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve |
|
||||
| AST | ast_grep_search, ast_grep_replace |
|
||||
| File Search | grep, glob |
|
||||
| Session | session_list, session_read, session_search, session_info |
|
||||
| Background | background_task, background_output, background_cancel |
|
||||
| Multimodal | look_at |
|
||||
| Terminal | interactive_bash |
|
||||
| Skills | skill, skill_mcp |
|
||||
| Agents | call_omo_agent |
|
||||
|
||||
## HOW TO ADD A TOOL
|
||||
## HOW TO ADD
|
||||
|
||||
1. Create directory: `src/tools/my-tool/`
|
||||
2. Create files:
|
||||
- `constants.ts`: `TOOL_NAME`, `TOOL_DESCRIPTION`
|
||||
- `types.ts`: Parameter/result interfaces
|
||||
- `tools.ts`: Tool implementation (returns OpenCode tool object)
|
||||
- `index.ts`: Barrel export
|
||||
- `utils.ts`: Helpers (optional)
|
||||
1. Create `src/tools/my-tool/`
|
||||
2. Files: `constants.ts`, `types.ts`, `tools.ts`, `index.ts`
|
||||
3. Add to `builtinTools` in `src/tools/index.ts`
|
||||
|
||||
## LSP SPECIFICS
|
||||
|
||||
- **Client lifecycle**: Lazy init on first use, auto-shutdown on idle
|
||||
- **Config priority**: opencode.json > oh-my-opencode.json > defaults
|
||||
- **Supported servers**: typescript-language-server, pylsp, gopls, rust-analyzer, etc.
|
||||
- **Custom servers**: Add via `lsp` config in oh-my-opencode.json
|
||||
- Lazy init on first use, auto-shutdown on idle
|
||||
- Config priority: opencode.json > oh-my-opencode.json > defaults
|
||||
- Servers: typescript-language-server, pylsp, gopls, rust-analyzer
|
||||
|
||||
## AST-GREP SPECIFICS
|
||||
|
||||
- **Meta-variables**: `$VAR` (single node), `$$$` (multiple nodes)
|
||||
- **Languages**: 25 supported (typescript, tsx, python, rust, go, etc.)
|
||||
- **Binding**: Prefers @ast-grep/napi (native), falls back to @ast-grep/cli
|
||||
- **Pattern must be valid AST**: `export async function $NAME($$$) { $$$ }` not fragments
|
||||
- Meta-variables: `$VAR` (single), `$$$` (multiple)
|
||||
- Pattern must be valid AST node, not fragment
|
||||
- Prefers napi binding for performance
|
||||
|
||||
## ANTI-PATTERNS (TOOLS)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **No timeout**: Always use timeout for file operations (default 60s)
|
||||
- **Blocking main thread**: Use async/await, never sync file ops
|
||||
- **Ignoring LSP errors**: Gracefully handle server not found/crashed
|
||||
- **Raw subprocess for ast-grep**: Prefer napi binding for performance
|
||||
- No timeout on file ops (always use, default 60s)
|
||||
- Sync file operations (use async/await)
|
||||
- Ignoring LSP errors (graceful handling required)
|
||||
- Raw subprocess for ast-grep (prefer napi)
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { createSkillTool } from "./skill"
|
||||
export { getTmuxPath } from "./interactive-bash/utils"
|
||||
export { createSkillMcpTool } from "./skill-mcp"
|
||||
|
||||
import {
|
||||
createBackgroundTask,
|
||||
|
||||
3
src/tools/skill-mcp/constants.ts
Normal file
3
src/tools/skill-mcp/constants.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export const SKILL_MCP_TOOL_NAME = "skill_mcp"
|
||||
|
||||
export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.`
|
||||
3
src/tools/skill-mcp/index.ts
Normal file
3
src/tools/skill-mcp/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
export { createSkillMcpTool } from "./tools"
|
||||
215
src/tools/skill-mcp/tools.test.ts
Normal file
215
src/tools/skill-mcp/tools.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, beforeEach, mock } from "bun:test"
|
||||
import { createSkillMcpTool, applyGrepFilter } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
|
||||
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
resolvedPath: `/test/skills/${name}`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill ${name}`,
|
||||
template: "Test template",
|
||||
},
|
||||
scope: "opencode-project",
|
||||
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
|
||||
}
|
||||
}
|
||||
|
||||
const mockContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "msg-1",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("skill_mcp tool", () => {
|
||||
let manager: SkillMcpManager
|
||||
let loadedSkills: LoadedSkill[]
|
||||
let sessionID: string
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
loadedSkills = []
|
||||
sessionID = "test-session-1"
|
||||
})
|
||||
|
||||
describe("parameter validation", () => {
|
||||
it("throws when no operation specified", async () => {
|
||||
// #given
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({ mcp_name: "test-server" }, mockContext)
|
||||
).rejects.toThrow(/Missing operation/)
|
||||
})
|
||||
|
||||
it("throws when multiple operations specified", async () => {
|
||||
// #given
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({
|
||||
mcp_name: "test-server",
|
||||
tool_name: "some-tool",
|
||||
resource_name: "some://resource",
|
||||
}, mockContext)
|
||||
).rejects.toThrow(/Multiple operations/)
|
||||
})
|
||||
|
||||
it("throws when mcp_name not found in any skill", async () => {
|
||||
// #given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
"known-server": { command: "echo", args: ["test"] },
|
||||
}),
|
||||
]
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
|
||||
).rejects.toThrow(/not found/)
|
||||
})
|
||||
|
||||
it("includes available MCP servers in error message", async () => {
|
||||
// #given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("db-skill", {
|
||||
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
|
||||
}),
|
||||
createMockSkillWithMcp("api-skill", {
|
||||
"rest-api": { command: "node", args: ["server.js"] },
|
||||
}),
|
||||
]
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
|
||||
).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s)
|
||||
})
|
||||
|
||||
it("throws on invalid JSON arguments", async () => {
|
||||
// #given
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
"test-server": { command: "echo" },
|
||||
}),
|
||||
]
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => loadedSkills,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
tool.execute({
|
||||
mcp_name: "test-server",
|
||||
tool_name: "some-tool",
|
||||
arguments: "not valid json",
|
||||
}, mockContext)
|
||||
).rejects.toThrow(/Invalid arguments JSON/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool description", () => {
|
||||
it("has concise description", () => {
|
||||
// #given / #when
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => [],
|
||||
getSessionID: () => "session",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(tool.description.length).toBeLessThan(200)
|
||||
expect(tool.description).toContain("mcp_name")
|
||||
})
|
||||
|
||||
it("includes grep parameter in schema", () => {
|
||||
// #given / #when
|
||||
const tool = createSkillMcpTool({
|
||||
manager,
|
||||
getLoadedSkills: () => [],
|
||||
getSessionID: () => "session",
|
||||
})
|
||||
|
||||
// #then
|
||||
expect(tool.description).toBeDefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyGrepFilter", () => {
|
||||
it("filters lines matching pattern", () => {
|
||||
// #given
|
||||
const output = `line1: hello world
|
||||
line2: foo bar
|
||||
line3: hello again
|
||||
line4: baz qux`
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, "hello")
|
||||
|
||||
// #then
|
||||
expect(result).toContain("line1: hello world")
|
||||
expect(result).toContain("line3: hello again")
|
||||
expect(result).not.toContain("foo bar")
|
||||
expect(result).not.toContain("baz qux")
|
||||
})
|
||||
|
||||
it("returns original output when pattern is undefined", () => {
|
||||
// #given
|
||||
const output = "some output"
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, undefined)
|
||||
|
||||
// #then
|
||||
expect(result).toBe(output)
|
||||
})
|
||||
|
||||
it("returns message when no lines match", () => {
|
||||
// #given
|
||||
const output = "line1\nline2\nline3"
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, "xyz")
|
||||
|
||||
// #then
|
||||
expect(result).toContain("[grep] No lines matched pattern")
|
||||
})
|
||||
|
||||
it("handles invalid regex gracefully", () => {
|
||||
// #given
|
||||
const output = "some output"
|
||||
|
||||
// #when
|
||||
const result = applyGrepFilter(output, "[invalid")
|
||||
|
||||
// #then
|
||||
expect(result).toBe(output)
|
||||
})
|
||||
})
|
||||
169
src/tools/skill-mcp/tools.ts
Normal file
169
src/tools/skill-mcp/tools.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { SKILL_MCP_DESCRIPTION } from "./constants"
|
||||
import type { SkillMcpArgs } from "./types"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
|
||||
interface SkillMcpToolOptions {
|
||||
manager: SkillMcpManager
|
||||
getLoadedSkills: () => LoadedSkill[]
|
||||
getSessionID: () => string
|
||||
}
|
||||
|
||||
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
|
||||
|
||||
function validateOperationParams(args: SkillMcpArgs): OperationType {
|
||||
const operations: OperationType[] = []
|
||||
if (args.tool_name) operations.push({ type: "tool", name: args.tool_name })
|
||||
if (args.resource_name) operations.push({ type: "resource", name: args.resource_name })
|
||||
if (args.prompt_name) operations.push({ type: "prompt", name: args.prompt_name })
|
||||
|
||||
if (operations.length === 0) {
|
||||
throw new Error(
|
||||
`Missing operation. Exactly one of tool_name, resource_name, or prompt_name must be specified.\n\n` +
|
||||
`Examples:\n` +
|
||||
` skill_mcp(mcp_name="sqlite", tool_name="query", arguments='{"sql": "SELECT * FROM users"}')\n` +
|
||||
` skill_mcp(mcp_name="memory", resource_name="memory://notes")\n` +
|
||||
` skill_mcp(mcp_name="helper", prompt_name="summarize", arguments='{"text": "..."}')`
|
||||
)
|
||||
}
|
||||
|
||||
if (operations.length > 1) {
|
||||
const provided = [
|
||||
args.tool_name && `tool_name="${args.tool_name}"`,
|
||||
args.resource_name && `resource_name="${args.resource_name}"`,
|
||||
args.prompt_name && `prompt_name="${args.prompt_name}"`,
|
||||
].filter(Boolean).join(", ")
|
||||
|
||||
throw new Error(
|
||||
`Multiple operations specified. Exactly one of tool_name, resource_name, or prompt_name must be provided.\n\n` +
|
||||
`Received: ${provided}\n\n` +
|
||||
`Use separate calls for each operation.`
|
||||
)
|
||||
}
|
||||
|
||||
return operations[0]
|
||||
}
|
||||
|
||||
function findMcpServer(
|
||||
mcpName: string,
|
||||
skills: LoadedSkill[]
|
||||
): { skill: LoadedSkill; config: NonNullable<LoadedSkill["mcpConfig"]>[string] } | null {
|
||||
for (const skill of skills) {
|
||||
if (skill.mcpConfig && mcpName in skill.mcpConfig) {
|
||||
return { skill, config: skill.mcpConfig[mcpName] }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatAvailableMcps(skills: LoadedSkill[]): string {
|
||||
const mcps: string[] = []
|
||||
for (const skill of skills) {
|
||||
if (skill.mcpConfig) {
|
||||
for (const serverName of Object.keys(skill.mcpConfig)) {
|
||||
mcps.push(` - "${serverName}" from skill "${skill.name}"`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mcps.length > 0 ? mcps.join("\n") : " (none found)"
|
||||
}
|
||||
|
||||
function parseArguments(argsJson: string | undefined): Record<string, unknown> {
|
||||
if (!argsJson) return {}
|
||||
try {
|
||||
const parsed = JSON.parse(argsJson)
|
||||
if (typeof parsed !== "object" || parsed === null) {
|
||||
throw new Error("Arguments must be a JSON object")
|
||||
}
|
||||
return parsed as Record<string, unknown>
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Invalid arguments JSON: ${errorMessage}\n\n` +
|
||||
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
|
||||
`Received: ${argsJson}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function applyGrepFilter(output: string, pattern: string | undefined): string {
|
||||
if (!pattern) return output
|
||||
try {
|
||||
const regex = new RegExp(pattern, "i")
|
||||
const lines = output.split("\n")
|
||||
const filtered = lines.filter(line => regex.test(line))
|
||||
return filtered.length > 0
|
||||
? filtered.join("\n")
|
||||
: `[grep] No lines matched pattern: ${pattern}`
|
||||
} catch {
|
||||
return output
|
||||
}
|
||||
}
|
||||
|
||||
export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition {
|
||||
const { manager, getLoadedSkills, getSessionID } = options
|
||||
|
||||
return tool({
|
||||
description: SKILL_MCP_DESCRIPTION,
|
||||
args: {
|
||||
mcp_name: tool.schema.string().describe("Name of the MCP server from skill config"),
|
||||
tool_name: tool.schema.string().optional().describe("MCP tool to call"),
|
||||
resource_name: tool.schema.string().optional().describe("MCP resource URI to read"),
|
||||
prompt_name: tool.schema.string().optional().describe("MCP prompt to get"),
|
||||
arguments: tool.schema.string().optional().describe("JSON string of arguments"),
|
||||
grep: tool.schema.string().optional().describe("Regex pattern to filter output lines (only matching lines returned)"),
|
||||
},
|
||||
async execute(args: SkillMcpArgs) {
|
||||
const operation = validateOperationParams(args)
|
||||
const skills = getLoadedSkills()
|
||||
const found = findMcpServer(args.mcp_name, skills)
|
||||
|
||||
if (!found) {
|
||||
throw new Error(
|
||||
`MCP server "${args.mcp_name}" not found.\n\n` +
|
||||
`Available MCP servers in loaded skills:\n` +
|
||||
formatAvailableMcps(skills) + `\n\n` +
|
||||
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`
|
||||
)
|
||||
}
|
||||
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: args.mcp_name,
|
||||
skillName: found.skill.name,
|
||||
sessionID: getSessionID(),
|
||||
}
|
||||
|
||||
const context: SkillMcpServerContext = {
|
||||
config: found.config,
|
||||
skillName: found.skill.name,
|
||||
}
|
||||
|
||||
const parsedArgs = parseArguments(args.arguments)
|
||||
|
||||
let output: string
|
||||
switch (operation.type) {
|
||||
case "tool": {
|
||||
const result = await manager.callTool(info, context, operation.name, parsedArgs)
|
||||
output = JSON.stringify(result, null, 2)
|
||||
break
|
||||
}
|
||||
case "resource": {
|
||||
const result = await manager.readResource(info, context, operation.name)
|
||||
output = JSON.stringify(result, null, 2)
|
||||
break
|
||||
}
|
||||
case "prompt": {
|
||||
const stringArgs: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(parsedArgs)) {
|
||||
stringArgs[key] = String(value)
|
||||
}
|
||||
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
|
||||
output = JSON.stringify(result, null, 2)
|
||||
break
|
||||
}
|
||||
}
|
||||
return applyGrepFilter(output, args.grep)
|
||||
},
|
||||
})
|
||||
}
|
||||
8
src/tools/skill-mcp/types.ts
Normal file
8
src/tools/skill-mcp/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SkillMcpArgs {
|
||||
mcp_name: string
|
||||
tool_name?: string
|
||||
resource_name?: string
|
||||
prompt_name?: string
|
||||
arguments?: string
|
||||
grep?: string
|
||||
}
|
||||
239
src/tools/skill/tools.test.ts
Normal file
239
src/tools/skill/tools.test.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
|
||||
import * as fs from "node:fs"
|
||||
import { createSkillTool } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs)
|
||||
|
||||
mock.module("node:fs", () => ({
|
||||
...fs,
|
||||
readFileSync: (path: string, encoding?: string) => {
|
||||
if (typeof path === "string" && path.includes("/skills/")) {
|
||||
return `---
|
||||
description: Test skill description
|
||||
---
|
||||
Test skill body content`
|
||||
}
|
||||
return originalReadFileSync(path, encoding as BufferEncoding)
|
||||
},
|
||||
}))
|
||||
|
||||
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
|
||||
return {
|
||||
name,
|
||||
path: `/test/skills/${name}/SKILL.md`,
|
||||
resolvedPath: `/test/skills/${name}`,
|
||||
definition: {
|
||||
name,
|
||||
description: `Test skill ${name}`,
|
||||
template: "Test template",
|
||||
},
|
||||
scope: "opencode-project",
|
||||
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
|
||||
}
|
||||
}
|
||||
|
||||
const mockContext = {
|
||||
sessionID: "test-session",
|
||||
messageID: "msg-1",
|
||||
agent: "test-agent",
|
||||
abort: new AbortController().signal,
|
||||
}
|
||||
|
||||
describe("skill tool - MCP schema display", () => {
|
||||
let manager: SkillMcpManager
|
||||
let loadedSkills: LoadedSkill[]
|
||||
let sessionID: string
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
loadedSkills = []
|
||||
sessionID = "test-session-1"
|
||||
})
|
||||
|
||||
describe("formatMcpCapabilities with inputSchema", () => {
|
||||
it("displays tool inputSchema when available", async () => {
|
||||
// #given
|
||||
const mockToolsWithSchema: McpTool[] = [
|
||||
{
|
||||
name: "browser_type",
|
||||
description: "Type text into an element",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
element: { type: "string", description: "Human-readable element description" },
|
||||
ref: { type: "string", description: "Element reference from page snapshot" },
|
||||
text: { type: "string", description: "Text to type into the element" },
|
||||
submit: { type: "boolean", description: "Submit form after typing" },
|
||||
},
|
||||
required: ["element", "ref", "text"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("test-skill", {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
||||
}),
|
||||
]
|
||||
|
||||
// Mock manager.listTools to return our mock tools
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "test-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
// Should include inputSchema details
|
||||
expect(result).toContain("browser_type")
|
||||
expect(result).toContain("inputSchema")
|
||||
expect(result).toContain("element")
|
||||
expect(result).toContain("ref")
|
||||
expect(result).toContain("text")
|
||||
expect(result).toContain("submit")
|
||||
expect(result).toContain("required")
|
||||
})
|
||||
|
||||
it("displays multiple tools with their schemas", async () => {
|
||||
// #given
|
||||
const mockToolsWithSchema: McpTool[] = [
|
||||
{
|
||||
name: "browser_navigate",
|
||||
description: "Navigate to a URL",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
url: { type: "string", description: "URL to navigate to" },
|
||||
},
|
||||
required: ["url"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "browser_click",
|
||||
description: "Click an element",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
element: { type: "string" },
|
||||
ref: { type: "string" },
|
||||
},
|
||||
required: ["element", "ref"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("playwright-skill", {
|
||||
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
||||
}),
|
||||
]
|
||||
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "playwright-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
expect(result).toContain("browser_navigate")
|
||||
expect(result).toContain("browser_click")
|
||||
expect(result).toContain("url")
|
||||
expect(result).toContain("Navigate to a URL")
|
||||
})
|
||||
|
||||
it("handles tools without inputSchema gracefully", async () => {
|
||||
// #given
|
||||
const mockToolsMinimal: McpTool[] = [
|
||||
{
|
||||
name: "simple_tool",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("simple-skill", {
|
||||
simple: { command: "echo", args: ["test"] },
|
||||
}),
|
||||
]
|
||||
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockToolsMinimal)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "simple-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
expect(result).toContain("simple_tool")
|
||||
// Should not throw, should handle gracefully
|
||||
})
|
||||
|
||||
it("formats schema in a way LLM can understand for skill_mcp calls", async () => {
|
||||
// #given
|
||||
const mockTools: McpTool[] = [
|
||||
{
|
||||
name: "query",
|
||||
description: "Execute SQL query",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sql: { type: "string", description: "SQL query to execute" },
|
||||
params: { type: "array", description: "Query parameters" },
|
||||
},
|
||||
required: ["sql"],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
loadedSkills = [
|
||||
createMockSkillWithMcp("db-skill", {
|
||||
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
|
||||
}),
|
||||
]
|
||||
|
||||
spyOn(manager, "listTools").mockResolvedValue(mockTools)
|
||||
spyOn(manager, "listResources").mockResolvedValue([])
|
||||
spyOn(manager, "listPrompts").mockResolvedValue([])
|
||||
|
||||
const tool = createSkillTool({
|
||||
skills: loadedSkills,
|
||||
mcpManager: manager,
|
||||
getSessionID: () => sessionID,
|
||||
})
|
||||
|
||||
// #when
|
||||
const result = await tool.execute({ name: "db-skill" }, mockContext)
|
||||
|
||||
// #then
|
||||
// Should provide enough info for LLM to construct valid skill_mcp call
|
||||
expect(result).toContain("sqlite")
|
||||
expect(result).toContain("query")
|
||||
expect(result).toContain("sql")
|
||||
expect(result).toContain("required")
|
||||
expect(result).toMatch(/sql[\s\S]*string/i)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,8 @@ import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants
|
||||
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
|
||||
import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
|
||||
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
|
||||
return {
|
||||
@@ -49,6 +51,77 @@ function extractSkillBody(skill: LoadedSkill): string {
|
||||
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
|
||||
}
|
||||
|
||||
async function formatMcpCapabilities(
|
||||
skill: LoadedSkill,
|
||||
manager: SkillMcpManager,
|
||||
sessionID: string
|
||||
): Promise<string | null> {
|
||||
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sections: string[] = ["", "## Available MCP Servers", ""]
|
||||
|
||||
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName,
|
||||
skillName: skill.name,
|
||||
sessionID,
|
||||
}
|
||||
const context: SkillMcpServerContext = {
|
||||
config,
|
||||
skillName: skill.name,
|
||||
}
|
||||
|
||||
sections.push(`### ${serverName}`)
|
||||
sections.push("")
|
||||
|
||||
try {
|
||||
const [tools, resources, prompts] = await Promise.all([
|
||||
manager.listTools(info, context).catch(() => []),
|
||||
manager.listResources(info, context).catch(() => []),
|
||||
manager.listPrompts(info, context).catch(() => []),
|
||||
])
|
||||
|
||||
if (tools.length > 0) {
|
||||
sections.push("**Tools:**")
|
||||
sections.push("")
|
||||
for (const t of tools as Tool[]) {
|
||||
sections.push(`#### \`${t.name}\``)
|
||||
if (t.description) {
|
||||
sections.push(t.description)
|
||||
}
|
||||
sections.push("")
|
||||
sections.push("**inputSchema:**")
|
||||
sections.push("```json")
|
||||
sections.push(JSON.stringify(t.inputSchema, null, 2))
|
||||
sections.push("```")
|
||||
sections.push("")
|
||||
}
|
||||
}
|
||||
if (resources.length > 0) {
|
||||
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
|
||||
}
|
||||
if (prompts.length > 0) {
|
||||
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
|
||||
}
|
||||
|
||||
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
|
||||
sections.push("*No capabilities discovered*")
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
|
||||
}
|
||||
|
||||
sections.push("")
|
||||
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
|
||||
const skillInfos = skills.map(loadedSkillToInfo)
|
||||
@@ -75,13 +148,26 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
const body = extractSkillBody(skill)
|
||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
||||
|
||||
return [
|
||||
const output = [
|
||||
`## Skill: ${skill.name}`,
|
||||
"",
|
||||
`**Base directory**: ${dir}`,
|
||||
"",
|
||||
body,
|
||||
].join("\n")
|
||||
]
|
||||
|
||||
if (options.mcpManager && options.getSessionID && skill.mcpConfig) {
|
||||
const mcpInfo = await formatMcpCapabilities(
|
||||
skill,
|
||||
options.mcpManager,
|
||||
options.getSessionID()
|
||||
)
|
||||
if (mcpInfo) {
|
||||
output.push(mcpInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return output.join("\n")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
|
||||
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
|
||||
export interface SkillArgs {
|
||||
name: string
|
||||
@@ -20,4 +21,8 @@ export interface SkillLoadOptions {
|
||||
opencodeOnly?: boolean
|
||||
/** Pre-merged skills to use instead of discovering */
|
||||
skills?: LoadedSkill[]
|
||||
/** MCP manager for querying skill-embedded MCP servers */
|
||||
mcpManager?: SkillMcpManager
|
||||
/** Session ID getter for MCP client identification */
|
||||
getSessionID?: () => string
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { tool, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
@@ -23,7 +24,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const metadata: CommandMetadata = {
|
||||
|
||||
Reference in New Issue
Block a user