Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf3dd91da2 | ||
|
|
fd957e7ed0 | ||
|
|
3ba61790ab | ||
|
|
3224c15578 | ||
|
|
a51ad98182 | ||
|
|
b98a1b28f8 | ||
|
|
9a92dc8d95 | ||
|
|
99711dacc1 | ||
|
|
6eaa96f421 | ||
|
|
f6b066ecfa | ||
|
|
4434a59cf0 | ||
|
|
038d838e63 | ||
|
|
dc057e9910 | ||
|
|
d4787c477a | ||
|
|
e6ffdc4352 | ||
|
|
a1fe0f8517 | ||
|
|
bebe6607d4 | ||
|
|
f088f008cc | ||
|
|
f64210c505 | ||
|
|
b75383fb99 |
138
AGENTS.md
138
AGENTS.md
@@ -1,30 +1,29 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-02T00:10:00+09:00
|
||||
**Commit:** b0c39e2
|
||||
**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
|
||||
│ ├── agents/ # 7 AI agents - see src/agents/AGENTS.md
|
||||
│ ├── hooks/ # 22 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 + core features - see src/features/AGENTS.md
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ ├── 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/ # Utilities: deep-merge, pattern-matcher, logger, etc. - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor, run - see src/cli/AGENTS.md
|
||||
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
|
||||
│ ├── 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
|
||||
│ └── 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)
|
||||
```
|
||||
|
||||
@@ -32,71 +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 MCP | `src/mcp/` | Create config, add to index.ts and types.ts |
|
||||
| Add skill | `src/features/builtin-skills/` | Create skill dir with SKILL.md |
|
||||
| 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/Gemini models |
|
||||
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| Skill MCP | `src/features/skill-mcp-manager/` | MCP servers embedded in skills |
|
||||
| Interactive terminal | `src/tools/interactive-bash/` | tmux session management |
|
||||
| CLI installer | `src/cli/install.ts` | Interactive TUI installation |
|
||||
| Doctor checks | `src/cli/doctor/checks/` | Health checks for environment |
|
||||
| Shared utilities | `src/shared/` | Cross-cutting utilities |
|
||||
| Slash commands | `src/hooks/auto-slash-command/` | Auto-detect and execute `/command` patterns |
|
||||
| Ralph Loop | `src/hooks/ralph-loop/` | Self-referential dev loop until completion |
|
||||
|
||||
## 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
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Sequential agent calls**: Use `background_task` for parallel execution
|
||||
- **Heavy PreToolUse logic**: Slows every tool call
|
||||
- **Self-planning for complex tasks**: Spawn planning agent (Prometheus) instead
|
||||
|
||||
## 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
|
||||
- **Factory pattern**: Components created via `createXXX()` functions
|
||||
| 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 |
|
||||
@@ -107,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
|
||||
@@ -116,37 +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 PIPELINE
|
||||
|
||||
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master, rolling `next` draft release
|
||||
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
|
||||
- **sisyphus-agent.yml**: Agent-in-CI for automated issue handling via `@sisyphus-dev-ai` mentions
|
||||
CI auto-commits schema changes on master, maintains rolling `next` draft release on dev.
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/index.ts` | 723 | Main plugin orchestration, all hook/tool initialization |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, environment detection, installation |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting, endpoint fallbacks |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, stdin/stdout buffering, JSON-RPC |
|
||||
| `src/auth/antigravity/response.ts` | 598 | Response transformation, streaming |
|
||||
| `src/auth/antigravity/thinking.ts` | 571 | Thinking block extraction/transformation |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Session compaction, multi-stage recovery pipeline |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt, delegation strategies |
|
||||
| `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`, 360+ tests
|
||||
- **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
|
||||
- **JSONC support**: Config files support comments (`// comment`, `/* block */`) and trailing commas
|
||||
- **Claude Code Compat**: Full compatibility layer for settings.json hooks, commands, skills, agents, MCPs
|
||||
- **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
|
||||
|
||||
10
README.ja.md
10
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`
|
||||
|
||||
10
README.ko.md
10
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`
|
||||
|
||||
30
README.md
30
README.md
@@ -152,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
|
||||
|
||||

|
||||
@@ -204,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**
|
||||
@@ -577,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.
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.10.0",
|
||||
"version": "2.11.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -76,20 +90,20 @@ 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) {
|
||||
if (agentName === "librarian" && directory && config.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
config = { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
@@ -99,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
|
||||
|
||||
@@ -2,56 +2,56 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Google Antigravity OAuth implementation for Gemini models. Token management, fetch interception, thinking block extraction, and response transformation.
|
||||
Google Antigravity OAuth for Gemini models. Token management, fetch interception, thinking block extraction.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
auth/
|
||||
└── antigravity/
|
||||
├── plugin.ts # Main plugin export, hooks registration
|
||||
├── plugin.ts # Main export, hooks registration
|
||||
├── oauth.ts # OAuth flow, token acquisition
|
||||
├── token.ts # Token storage, refresh logic
|
||||
├── fetch.ts # Fetch interceptor (622 lines) - URL rewriting, retry
|
||||
├── response.ts # Response transformation, streaming
|
||||
├── thinking.ts # Thinking block extraction/transformation
|
||||
├── thought-signature-store.ts # Signature caching for thinking blocks
|
||||
├── message-converter.ts # Message format conversion
|
||||
├── request.ts # Request building, headers
|
||||
├── 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 # Tool registration for OAuth
|
||||
├── tools.ts # OAuth tool registration
|
||||
├── constants.ts # API endpoints, model mappings
|
||||
└── types.ts # TypeScript interfaces
|
||||
└── types.ts
|
||||
```
|
||||
|
||||
## KEY COMPONENTS
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `fetch.ts` | Core interceptor - rewrites URLs, manages tokens, handles retries |
|
||||
| `thinking.ts` | Extracts `<antThinking>` blocks, transforms for OpenCode compatibility |
|
||||
| `response.ts` | Handles streaming responses, SSE parsing |
|
||||
| `oauth.ts` | Browser-based OAuth flow for Google accounts |
|
||||
| `token.ts` | Token persistence, expiry checking, refresh |
|
||||
| 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 requests to Anthropic/Google endpoints
|
||||
2. **Rewrite**: URLs rewritten to Antigravity proxy endpoints
|
||||
3. **Auth**: Bearer token injected from stored OAuth credentials
|
||||
4. **Response**: Streaming responses parsed, thinking blocks extracted
|
||||
5. **Transform**: Response format normalized for OpenCode consumption
|
||||
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
|
||||
|
||||
## ANTI-PATTERNS (AUTH)
|
||||
## FEATURES
|
||||
|
||||
- **Direct API calls**: Always go through fetch interceptor
|
||||
- **Storing tokens in code**: Use `token.ts` storage layer
|
||||
- **Ignoring refresh**: Check token expiry before requests
|
||||
- **Blocking on OAuth**: OAuth flow is async, never block main thread
|
||||
- Multi-account (up to 10 Google accounts)
|
||||
- Auto-fallback on rate limit
|
||||
- Thinking blocks preserved
|
||||
- Antigravity proxy for AI Studio access
|
||||
|
||||
## NOTES
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Multi-account**: Supports up to 10 Google accounts for load balancing
|
||||
- **Fallback**: On rate limit, automatically switches to next available account
|
||||
- **Thinking blocks**: Preserved and transformed for extended thinking features
|
||||
- **Proxy**: Uses Antigravity proxy for Google AI Studio access
|
||||
- Direct API calls (use fetch interceptor)
|
||||
- Tokens in code (use token.ts storage)
|
||||
- Ignoring refresh (check expiry first)
|
||||
- Blocking on OAuth (always async)
|
||||
|
||||
@@ -2,92 +2,67 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Command-line interface for oh-my-opencode. Interactive installer, health diagnostics (doctor), and runtime commands. Entry point: `bunx oh-my-opencode`.
|
||||
CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runtime launcher. Entry: `bunx oh-my-opencode`.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry point, subcommand routing
|
||||
├── install.ts # Interactive TUI installer
|
||||
├── config-manager.ts # Config detection, parsing, merging (669 lines)
|
||||
├── 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, descriptions
|
||||
│ ├── constants.ts # Check categories
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 17 individual health checks
|
||||
├── get-local-version/ # Version detection utility
|
||||
│ ├── index.ts
|
||||
│ └── formatter.ts
|
||||
│ └── checks/ # 17+ individual checks
|
||||
├── get-local-version/ # Version detection
|
||||
└── run/ # OpenCode session launcher
|
||||
├── index.ts
|
||||
└── completion.test.ts
|
||||
```
|
||||
|
||||
## CLI COMMANDS
|
||||
|
||||
| Command | Purpose | Key File |
|
||||
|---------|---------|----------|
|
||||
| `install` | Interactive setup wizard | `install.ts` |
|
||||
| `doctor` | Environment health checks | `doctor/index.ts` |
|
||||
| `run` | Launch OpenCode session | `run/index.ts` |
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `install` | Interactive setup wizard |
|
||||
| `doctor` | Environment health checks |
|
||||
| `run` | Launch OpenCode session |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
|
||||
17 checks in `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
|
||||
|
||||
| Check | Validates |
|
||||
|-------|-----------|
|
||||
| `version.ts` | OpenCode version >= 1.0.150 |
|
||||
| `config.ts` | Plugin registered in opencode.json |
|
||||
| `bun.ts` | Bun runtime available |
|
||||
| `node.ts` | Node.js version compatibility |
|
||||
| `git.ts` | Git installed |
|
||||
| `anthropic-auth.ts` | Claude authentication |
|
||||
| `openai-auth.ts` | OpenAI authentication |
|
||||
| `google-auth.ts` | Google/Gemini authentication |
|
||||
| `lsp-*.ts` | Language server availability |
|
||||
| `mcp-*.ts` | MCP server connectivity |
|
||||
## CONFIG-MANAGER (669 lines)
|
||||
|
||||
## INSTALLATION FLOW
|
||||
- JSONC support (comments, trailing commas)
|
||||
- Multi-source: User (~/.config/opencode/) + Project (.opencode/)
|
||||
- Zod validation
|
||||
- Legacy format migration
|
||||
- Error aggregation for doctor
|
||||
|
||||
1. **Detection**: Find existing `opencode.json` / `opencode.jsonc`
|
||||
2. **TUI Prompts**: Claude subscription? ChatGPT? Gemini?
|
||||
3. **Config Generation**: Build `oh-my-opencode.json` based on answers
|
||||
4. **Plugin Registration**: Add to `plugin` array in opencode.json
|
||||
5. **Auth Guidance**: Instructions for `opencode auth login`
|
||||
|
||||
## CONFIG-MANAGER
|
||||
|
||||
The largest file (669 lines) handles:
|
||||
|
||||
- **JSONC support**: Parses comments and trailing commas
|
||||
- **Multi-source detection**: User (~/.config/opencode/) + Project (.opencode/)
|
||||
- **Schema validation**: Zod-based config validation
|
||||
- **Migration**: Handles legacy config formats
|
||||
- **Error collection**: Aggregates parsing errors for doctor
|
||||
|
||||
## HOW TO ADD A DOCTOR CHECK
|
||||
## HOW TO ADD CHECK
|
||||
|
||||
1. Create `src/cli/doctor/checks/my-check.ts`:
|
||||
```typescript
|
||||
import type { DoctorCheck } from "../types"
|
||||
|
||||
export const myCheck: DoctorCheck = {
|
||||
name: "my-check",
|
||||
category: "environment",
|
||||
check: async () => {
|
||||
// Return { status: "pass" | "warn" | "fail", message: string }
|
||||
return { status: "pass" | "warn" | "fail", message: "..." }
|
||||
}
|
||||
}
|
||||
```
|
||||
2. Add to `src/cli/doctor/checks/index.ts`
|
||||
3. Update `constants.ts` if new category
|
||||
|
||||
## ANTI-PATTERNS (CLI)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Blocking prompts in non-TTY**: Check `process.stdout.isTTY` before TUI
|
||||
- **Hardcoded paths**: Use shared utilities for config paths
|
||||
- **Ignoring JSONC**: User configs may have comments
|
||||
- **Silent failures**: Doctor checks must return clear status/message
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,97 +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
|
||||
├── builtin-skills/ # Built-in skills (playwright, etc.)
|
||||
│ └── */SKILL.md # Each skill in own directory
|
||||
├── 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
|
||||
├── skill-mcp-manager/ # MCP servers embedded in skills
|
||||
│ ├── manager.ts # Lazy-loading MCP client lifecycle
|
||||
│ └── types.ts
|
||||
├── 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
|
||||
|
||||
## SKILL MCP MANAGER
|
||||
## SKILL MCP
|
||||
|
||||
- **Purpose**: Manage MCP servers embedded in skill YAML frontmatter
|
||||
- **Lifecycle**: Lazy client loading, session-scoped cleanup
|
||||
- **Config**: `mcp` field in skill's YAML frontmatter defines server config
|
||||
- **Tool**: `skill_mcp` exposes MCP capabilities (tools, resources, prompts)
|
||||
- MCP servers embedded in skill YAML frontmatter
|
||||
- Lazy client loading, session-scoped cleanup
|
||||
- `skill_mcp` tool exposes capabilities
|
||||
|
||||
## BUILTIN SKILLS
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Location**: `src/features/builtin-skills/*/SKILL.md`
|
||||
- **Available**: `playwright` (browser automation)
|
||||
- **Disable**: `disabled_skills: ["playwright"]` in config
|
||||
|
||||
## ANTI-PATTERNS (FEATURES)
|
||||
|
||||
- **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/
|
||||
- Blocking on load (loaders run at startup)
|
||||
- No error handling (always try/catch)
|
||||
- Ignoring priority order
|
||||
- Writing to ~/.claude/ (read-only)
|
||||
|
||||
@@ -78,6 +78,7 @@ $ARGUMENTS
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -134,7 +134,7 @@ Skill with env vars.
|
||||
})
|
||||
|
||||
it("handles malformed YAML gracefully", async () => {
|
||||
// #given
|
||||
// #given - malformed YAML causes entire frontmatter to fail parsing
|
||||
const skillContent = `---
|
||||
name: bad-yaml
|
||||
mcp: [this is not valid yaml for mcp
|
||||
@@ -150,9 +150,9 @@ Skill body.
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "bad-yaml")
|
||||
// #then - when YAML fails, skill uses directory name as fallback
|
||||
const skill = skills.find(s => s.name === "bad-yaml-skill")
|
||||
|
||||
// #then - should still load skill but without MCP config
|
||||
expect(skill).toBeDefined()
|
||||
expect(skill?.mcpConfig).toBeUndefined()
|
||||
} finally {
|
||||
|
||||
@@ -106,4 +106,54 @@ describe("SkillMcpManager", () => {
|
||||
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")
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -55,18 +55,21 @@ export class SkillMcpManager {
|
||||
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) {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) mergedEnv[key] = value
|
||||
}
|
||||
Object.assign(mergedEnv, config.env)
|
||||
}
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
args,
|
||||
env: config.env ? mergedEnv : undefined,
|
||||
env: mergedEnv,
|
||||
stderr: "ignore",
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
|
||||
@@ -2,85 +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-slash-command/ # Auto-detect and execute /command patterns
|
||||
├── 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
|
||||
├── ralph-loop/ # Self-referential dev loop until completion
|
||||
├── 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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -401,21 +401,31 @@ export async function executeCompact(
|
||||
|
||||
log("[auto-compact] aggressive truncation completed", aggressiveResult);
|
||||
|
||||
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;
|
||||
// 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({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
// 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 no tool outputs to truncate
|
||||
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
|
||||
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
|
||||
|
||||
if (errorData?.errorType?.includes("non-empty content")) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,5 +44,5 @@ export const TRUNCATE_CONFIG = {
|
||||
maxTruncateAttempts: 20,
|
||||
minOutputSizeToTruncate: 500,
|
||||
targetTokenRatio: 0.5,
|
||||
charsPerToken: 2,
|
||||
charsPerToken: 4,
|
||||
} as const
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
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"
|
||||
@@ -49,7 +50,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -164,42 +164,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 +211,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)
|
||||
})
|
||||
|
||||
@@ -401,4 +401,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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -155,10 +154,7 @@ export function createTodoContinuationEnforcer(
|
||||
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")
|
||||
@@ -251,10 +247,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
|
||||
}
|
||||
|
||||
@@ -280,8 +277,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 +323,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 +345,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 +357,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
|
||||
|
||||
483
src/index.ts
483
src/index.ts
@@ -1,5 +1,4 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { createBuiltinAgents } from "./agents";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
@@ -29,17 +28,6 @@ import {
|
||||
} 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,
|
||||
@@ -47,145 +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, createSkillMcpTool, interactive_bash, getTmuxPath } from "./tools";
|
||||
import {
|
||||
builtinTools,
|
||||
createCallOmoAgent,
|
||||
createBackgroundTools,
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
interactive_bash,
|
||||
getTmuxPath,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
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 ?? []),
|
||||
]),
|
||||
],
|
||||
disabled_skills: [
|
||||
...new Set([
|
||||
...(base.disabled_skills ?? []),
|
||||
...(override.disabled_skills ?? []),
|
||||
]),
|
||||
],
|
||||
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 { 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)
|
||||
@@ -201,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)
|
||||
@@ -212,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,
|
||||
@@ -231,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")
|
||||
@@ -279,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")
|
||||
@@ -290,9 +173,16 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
|
||||
const lookAt = createLookAt(ctx);
|
||||
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
|
||||
const builtinSkills = createBuiltinSkills().filter(
|
||||
(skill) => !disabledSkills.has(skill.name as any)
|
||||
);
|
||||
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,
|
||||
@@ -300,7 +190,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : [],
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverOpencodeProjectSkills()
|
||||
);
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
@@ -315,12 +205,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
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 } : {}),
|
||||
|
||||
@@ -340,34 +237,54 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -377,209 +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,
|
||||
"grep_app_*": false, // Disable grep_app tools globally to reduce token usage (only librarian needs them)
|
||||
};
|
||||
|
||||
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,
|
||||
"grep_app_*": true,
|
||||
};
|
||||
}
|
||||
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);
|
||||
@@ -658,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),
|
||||
@@ -673,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;
|
||||
}
|
||||
@@ -2,81 +2,62 @@
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
Cross-cutting utility functions used across agents, hooks, tools, and features. Path resolution, config management, text processing, and Claude Code compatibility helpers.
|
||||
Cross-cutting utilities: path resolution, config management, text processing, Claude Code compatibility helpers.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export (import { x } from "../shared")
|
||||
├── claude-config-dir.ts # Resolve ~/.claude directory
|
||||
├── command-executor.ts # Shell command execution with variable expansion
|
||||
├── config-errors.ts # Global config error tracking
|
||||
├── config-path.ts # User/project config path resolution
|
||||
├── data-path.ts # XDG data directory resolution
|
||||
├── deep-merge.ts # Type-safe recursive object merging
|
||||
├── dynamic-truncator.ts # Token-aware output truncation
|
||||
├── file-reference-resolver.ts # @filename syntax resolution
|
||||
├── file-utils.ts # Symlink resolution, markdown detection
|
||||
├── 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 is disabled in config
|
||||
├── jsonc-parser.ts # JSON with Comments parsing
|
||||
├── logger.ts # File-based logging to OS temp
|
||||
├── migration.ts # Legacy name compatibility (omo -> Sisyphus)
|
||||
├── 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 with wildcards
|
||||
├── snake-case.ts # Case conversion for objects
|
||||
└── tool-name.ts # Normalize tool names to PascalCase
|
||||
├── pattern-matcher.ts # Tool name matching
|
||||
├── snake-case.ts # Case conversion
|
||||
└── tool-name.ts # PascalCase normalization
|
||||
```
|
||||
|
||||
## UTILITY CATEGORIES
|
||||
## WHEN TO USE
|
||||
|
||||
| Category | Utilities | Used By |
|
||||
|----------|-----------|---------|
|
||||
| Path Resolution | `getClaudeConfigDir`, `getUserConfigPath`, `getProjectConfigPath`, `getDataDir` | Features, Hooks |
|
||||
| Config Management | `deepMerge`, `parseJsonc`, `isHookDisabled`, `configErrors` | index.ts, CLI |
|
||||
| Text Processing | `resolveCommandsInText`, `resolveFileReferencesInText`, `parseFrontmatter` | Commands, Rules |
|
||||
| Output Control | `dynamicTruncate` | Tools (Grep, LSP) |
|
||||
| Normalization | `transformToolName`, `objectToSnakeCase`, `sanitizeModelName` | Hooks, Agents |
|
||||
| Compatibility | `migration.ts` | Config loading |
|
||||
|
||||
## WHEN TO USE WHAT
|
||||
|
||||
| Task | Utility | Notes |
|
||||
|------|---------|-------|
|
||||
| Find Claude Code configs | `getClaudeConfigDir()` | Never hardcode `~/.claude` |
|
||||
| Merge settings (default → user → project) | `deepMerge(base, override)` | Arrays replaced, objects merged |
|
||||
| Parse user config files | `parseJsonc()` | Supports comments and trailing commas |
|
||||
| Check if hook should run | `isHookDisabled(name, disabledHooks)` | Respects `disabled_hooks` config |
|
||||
| Truncate large tool output | `dynamicTruncate(text, budget, reserved)` | Token-aware, prevents overflow |
|
||||
| Resolve `@file` references | `resolveFileReferencesInText()` | maxDepth=3 prevents infinite loops |
|
||||
| Execute shell commands | `resolveCommandsInText()` | Supports `!`\`command\`\` syntax |
|
||||
| Handle legacy agent names | `migrateLegacyAgentNames()` | `omo` → `Sisyphus` |
|
||||
| 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
|
||||
|
||||
### Dynamic Truncation
|
||||
```typescript
|
||||
import { dynamicTruncate } from "../shared"
|
||||
// Keep 50% headroom, max 50k tokens
|
||||
// Dynamic truncation
|
||||
const output = dynamicTruncate(result, remainingTokens, 0.5)
|
||||
```
|
||||
|
||||
### Deep Merge Priority
|
||||
```typescript
|
||||
const final = deepMerge(defaults, userConfig)
|
||||
final = deepMerge(final, projectConfig) // Project wins
|
||||
```
|
||||
// Deep merge priority
|
||||
const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
|
||||
|
||||
### Safe JSONC Parsing
|
||||
```typescript
|
||||
// Safe JSONC
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
if (error) return fallback
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS (SHARED)
|
||||
## ANTI-PATTERNS
|
||||
|
||||
- **Hardcoding paths**: Use `getClaudeConfigDir()`, `getUserConfigPath()`
|
||||
- **Manual JSON.parse**: Use `parseJsonc()` for user files (comments allowed)
|
||||
- **Ignoring truncation**: Large outputs MUST use `dynamicTruncate`
|
||||
- **Direct string concat for configs**: Use `deepMerge` for proper priority
|
||||
- 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,33 +2,26 @@
|
||||
|
||||
## 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
|
||||
@@ -37,47 +30,39 @@ tools/
|
||||
|
||||
## 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 |
|
||||
| Skills | skill, skill_mcp | Load skills, invoke skill-embedded MCPs |
|
||||
| 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)
|
||||
|
||||
@@ -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