Compare commits
97 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad98e88ec | ||
|
|
e264cd5078 | ||
|
|
0230e71bc6 | ||
|
|
c9f762f980 | ||
|
|
f658544cd6 | ||
|
|
396043a122 | ||
|
|
9854e9f6e5 | ||
|
|
48167a6920 | ||
|
|
207a39b17a | ||
|
|
5de3d4fb7d | ||
|
|
7a9e604b2d | ||
|
|
6670754efe | ||
|
|
37d4aec4d0 | ||
|
|
c38b078c12 | ||
|
|
5e44996746 | ||
|
|
9a152bcebb | ||
|
|
c67ca8275e | ||
|
|
72a3975799 | ||
|
|
747d824cbf | ||
|
|
b8a8cc95e2 | ||
|
|
96630bb0ee | ||
|
|
15e3e16bf2 | ||
|
|
68699330b8 | ||
|
|
49384fa804 | ||
|
|
b056e775f5 | ||
|
|
9bed597e46 | ||
|
|
74f355322a | ||
|
|
1ea304513c | ||
|
|
e925ed0009 | ||
|
|
fc5c2baac0 | ||
|
|
abc4a34ce4 | ||
|
|
d6499cbe31 | ||
|
|
a38dc28e40 | ||
|
|
89fa9ff167 | ||
|
|
4c22d6de76 | ||
|
|
1dd369fda5 | ||
|
|
84e97ba900 | ||
|
|
ef65f405e8 | ||
|
|
3de559ff87 | ||
|
|
acb16bcb27 | ||
|
|
9995b680f7 | ||
|
|
41fa37eb11 | ||
|
|
70bca4a7a6 | ||
|
|
b1f19cbfbd | ||
|
|
8395a6eaac | ||
|
|
abd1ec1092 | ||
|
|
5a8d9f09d9 | ||
|
|
2c4730f094 | ||
|
|
951df07c0f | ||
|
|
4c49299a93 | ||
|
|
00508e9959 | ||
|
|
c9ef648c60 | ||
|
|
8a9ebe1012 | ||
|
|
014bdaeec2 | ||
|
|
570b51d07b | ||
|
|
a91b05d9c6 | ||
|
|
4a892a9809 | ||
|
|
4d4966362f | ||
|
|
0c21c72e05 | ||
|
|
caf50fc4c9 | ||
|
|
3801e42ccb | ||
|
|
306dab41ad | ||
|
|
9f040e020f | ||
|
|
25dbcfe200 | ||
|
|
47a641c415 | ||
|
|
5c4f4fc655 | ||
|
|
2e1b467de4 | ||
|
|
e180d295bb | ||
|
|
93e59da9d6 | ||
|
|
358bd8d7fa | ||
|
|
78d67582d6 | ||
|
|
54575ad259 | ||
|
|
045fa79d92 | ||
|
|
4d966ec99b | ||
|
|
5d99e9ab64 | ||
|
|
129388387b | ||
|
|
c196db2a0e | ||
|
|
6ded689d08 | ||
|
|
45d660176e | ||
|
|
ffbab8f316 | ||
|
|
e203130ed8 | ||
|
|
0631865c16 | ||
|
|
2b036e7476 | ||
|
|
e6a572824c | ||
|
|
4d76f37bfe | ||
|
|
84e1ee09f0 | ||
|
|
3d5319a72d | ||
|
|
75eb82ea32 | ||
|
|
325ce1212b | ||
|
|
66f8946ff1 | ||
|
|
22619d137e | ||
|
|
000a61c961 | ||
|
|
9f07aae0a1 | ||
|
|
d7326e1eeb | ||
|
|
4a722df8be | ||
|
|
1a5fdb3338 | ||
|
|
c29e6f0213 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [master, dev]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
branches: [dev]
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
14
.github/workflows/publish.yml
vendored
14
.github/workflows/publish.yml
vendored
@@ -77,6 +77,7 @@ jobs:
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Upgrade npm for OIDC trusted publishing
|
||||
run: npm install -g npm@latest
|
||||
@@ -109,9 +110,12 @@ jobs:
|
||||
echo "=== Running bun build (CLI) ==="
|
||||
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi
|
||||
echo "=== Running tsc ==="
|
||||
tsc --emitDeclarationOnly
|
||||
bunx tsc --emitDeclarationOnly
|
||||
echo "=== Running build:schema ==="
|
||||
bun run build:schema
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun run build:binaries
|
||||
|
||||
- name: Verify build output
|
||||
run: |
|
||||
@@ -121,6 +125,13 @@ jobs:
|
||||
ls -la dist/cli/
|
||||
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
|
||||
test -f dist/cli/index.js || (echo "ERROR: dist/cli/index.js not found!" && exit 1)
|
||||
echo "=== Platform binaries ==="
|
||||
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl; do
|
||||
test -f "packages/${platform}/bin/oh-my-opencode" || (echo "ERROR: packages/${platform}/bin/oh-my-opencode not found!" && exit 1)
|
||||
echo "✓ packages/${platform}/bin/oh-my-opencode"
|
||||
done
|
||||
test -f "packages/windows-x64/bin/oh-my-opencode.exe" || (echo "ERROR: packages/windows-x64/bin/oh-my-opencode.exe not found!" && exit 1)
|
||||
echo "✓ packages/windows-x64/bin/oh-my-opencode.exe"
|
||||
|
||||
- name: Publish
|
||||
run: bun run script/publish.ts
|
||||
@@ -130,6 +141,7 @@ jobs:
|
||||
CI: true
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NPM_CONFIG_PROVENANCE: true
|
||||
SKIP_PLATFORM_PACKAGES: true
|
||||
|
||||
- name: Delete draft release
|
||||
run: gh release delete next --yes 2>/dev/null || echo "No draft release to delete"
|
||||
|
||||
6
.github/workflows/sisyphus-agent.yml
vendored
6
.github/workflows/sisyphus-agent.yml
vendored
@@ -103,7 +103,7 @@ jobs:
|
||||
opencode --version
|
||||
|
||||
# Run local oh-my-opencode install (uses built dist)
|
||||
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no
|
||||
bun run dist/cli/index.js install --no-tui --claude=max20 --chatgpt=no --gemini=no --copilot=no
|
||||
|
||||
# Override plugin to use local file reference
|
||||
OPENCODE_JSON=~/.config/opencode/opencode.json
|
||||
@@ -430,6 +430,10 @@ jobs:
|
||||
2. **CREATE TODOS IMMEDIATELY**: Right after reading, create your todo list using todo tools.
|
||||
- First todo: "Summarize issue/PR context and requirements"
|
||||
- Break down ALL work into atomic, verifiable steps
|
||||
- **GIT WORKFLOW (MANDATORY for implementation tasks)**: ALWAYS include these final todos:
|
||||
- "Create new branch from origin/BRANCH_PLACEHOLDER (NEVER push directly to BRANCH_PLACEHOLDER)"
|
||||
- "Commit changes"
|
||||
- "Create PR to BRANCH_PLACEHOLDER branch"
|
||||
- Plan everything BEFORE starting any work
|
||||
|
||||
---
|
||||
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -5,6 +5,10 @@ node_modules/
|
||||
# Build output
|
||||
dist/
|
||||
|
||||
# Platform binaries (built, not committed)
|
||||
packages/*/bin/oh-my-opencode
|
||||
packages/*/bin/oh-my-opencode.exe
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
43
AGENTS.md
43
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-13T14:45:00+09:00
|
||||
**Commit:** e47b5514
|
||||
**Generated:** 2026-01-15T14:53:00+09:00
|
||||
**Commit:** 89fa9ff1
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
@@ -13,16 +13,15 @@ OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orc
|
||||
```
|
||||
oh-my-opencode/
|
||||
├── src/
|
||||
│ ├── agents/ # AI agents (7+): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker, prometheus, metis, momus
|
||||
│ ├── agents/ # AI agents (10+): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker, prometheus, metis, momus
|
||||
│ ├── hooks/ # 22+ lifecycle hooks - see src/hooks/AGENTS.md
|
||||
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, session mgmt - see src/tools/AGENTS.md
|
||||
│ ├── features/ # Claude Code compat layer - see src/features/AGENTS.md
|
||||
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
|
||||
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, grep_app, websearch
|
||||
│ ├── config/ # Zod schema (12k lines), TypeScript types
|
||||
│ └── index.ts # Main plugin entry (563 lines)
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (580 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
├── assets/ # JSON schema
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
@@ -39,7 +38,6 @@ oh-my-opencode/
|
||||
| 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 |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
| Background agents | `src/features/background-agent/` | manager.ts for task management |
|
||||
@@ -50,7 +48,7 @@ oh-my-opencode/
|
||||
| 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 |
|
||||
| Orchestrator | `src/hooks/sisyphus-orchestrator/` | Main orchestration hook (677 lines) |
|
||||
| Orchestrator | `src/hooks/sisyphus-orchestrator/` | Main orchestration hook (684 lines) |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
@@ -83,7 +81,7 @@ oh-my-opencode/
|
||||
- **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`, TDD workflow (RED-GREEN-REFACTOR), 82 test files
|
||||
- **Testing**: BDD comments `#given/#when/#then`, TDD workflow (RED-GREEN-REFACTOR), 80+ test files
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
@@ -140,7 +138,7 @@ 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 (82 test files, 2559+ BDD assertions)
|
||||
bun test # Run tests (80+ test files, 2500+ BDD assertions)
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
@@ -157,26 +155,23 @@ bun test # Run tests (82 test files, 2559+ BDD assertions)
|
||||
|
||||
- **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
|
||||
|
||||
## COMPLEXITY HOTSPOTS
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/agents/orchestrator-sisyphus.ts` | 1486 | Orchestrator agent, 7-section delegation, accumulated wisdom |
|
||||
| `src/agents/orchestrator-sisyphus.ts` | 1485 | Orchestrator agent, 7-section delegation, accumulated wisdom |
|
||||
| `src/features/builtin-skills/skills.ts` | 1230 | Skill definitions (frontend-ui-ux, playwright) |
|
||||
| `src/agents/prometheus-prompt.ts` | 988 | Planning agent, interview mode, multi-agent validation |
|
||||
| `src/auth/antigravity/fetch.ts` | 798 | Token refresh, multi-account rotation, endpoint fallback |
|
||||
| `src/auth/antigravity/thinking.ts` | 755 | Thinking block extraction, signature management |
|
||||
| `src/cli/config-manager.ts` | 725 | JSONC parsing, multi-level config, env detection |
|
||||
| `src/hooks/sisyphus-orchestrator/index.ts` | 677 | Orchestrator hook impl |
|
||||
| `src/agents/prometheus-prompt.ts` | 991 | Planning agent, interview mode, multi-agent validation |
|
||||
| `src/features/background-agent/manager.ts` | 928 | Task lifecycle, concurrency |
|
||||
| `src/cli/config-manager.ts` | 730 | JSONC parsing, multi-level config, env detection |
|
||||
| `src/hooks/sisyphus-orchestrator/index.ts` | 684 | Orchestrator hook impl |
|
||||
| `src/tools/sisyphus-task/tools.ts` | 667 | Category-based task delegation |
|
||||
| `src/agents/sisyphus.ts` | 643 | Main Sisyphus prompt |
|
||||
| `src/tools/lsp/client.ts` | 632 | LSP protocol, JSON-RPC |
|
||||
| `src/features/background-agent/manager.ts` | 825 | Task lifecycle, concurrency |
|
||||
| `src/auth/antigravity/response.ts` | 598 | Response transformation, streaming |
|
||||
| `src/tools/sisyphus-task/tools.ts` | 583 | Category-based task delegation |
|
||||
| `src/index.ts` | 563 | Main plugin, all hook/tool init |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 555 | Multi-stage recovery |
|
||||
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactoring command template |
|
||||
| `src/index.ts` | 580 | Main plugin, all hook/tool init |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
|
||||
|
||||
## MCP ARCHITECTURE
|
||||
|
||||
@@ -187,14 +182,14 @@ Three-tier MCP system:
|
||||
|
||||
## CONFIG SYSTEM
|
||||
|
||||
- **Zod validation**: `src/config/schema.ts` (12k lines)
|
||||
- **Zod validation**: `src/config/schema.ts`
|
||||
- **JSONC support**: Comments and trailing commas
|
||||
- **Multi-level**: User (`~/.config/opencode/`) → Project (`.opencode/`)
|
||||
- **CLI doctor**: Validates config and reports errors
|
||||
|
||||
## NOTES
|
||||
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 82 test files
|
||||
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`, 80+ test files
|
||||
- **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)
|
||||
|
||||
86
README.ja.md
86
README.ja.md
@@ -28,7 +28,29 @@
|
||||
|
||||
> `oh-my-opencode` をインストールして、ドーピングしたかのようにコーディングしましょう。バックグラウンドでエージェントを走らせ、oracle、librarian、frontend engineer のような専門エージェントを呼び出してください。丹精込めて作られた LSP/AST ツール、厳選された MCP、そして完全な Claude Code 互換レイヤーを、たった一行で手に入れましょう。
|
||||
|
||||
**注意: librarianには高価なモデルを使用しないでください。これはあなたにとって役に立たないだけでなく、LLMプロバイダーにも負担をかけます。代わりにClaude Haiku、Gemini Flash、GLM 4.7、MiniMaxなどのモデルを使用してください。**
|
||||
# Claude OAuth アクセスに関するお知らせ
|
||||
|
||||
## TL;DR
|
||||
|
||||
> Q. oh-my-opencodeを使用できますか?
|
||||
|
||||
はい。
|
||||
|
||||
> Q. Claude Codeのサブスクリプションで使用できますか?
|
||||
|
||||
はい、技術的には可能です。ただし、使用を推奨することはできません。
|
||||
|
||||
## 詳細
|
||||
|
||||
> 2026年1月より、AnthropicはToS違反を理由にサードパーティのOAuthアクセスを制限しました。
|
||||
>
|
||||
> [**Anthropicはこのプロジェクト oh-my-opencode を、opencodeをブロックする正当化の根拠として挙げています。**](https://x.com/thdxr/status/2010149530486911014)
|
||||
>
|
||||
> 実際、Claude CodeのOAuthリクエストシグネチャを偽装するプラグインがコミュニティに存在します。
|
||||
>
|
||||
> これらのツールは技術的な検出可能性に関わらず動作する可能性がありますが、ユーザーはToSへの影響を認識すべきであり、私個人としてはそれらの使用を推奨できません。
|
||||
>
|
||||
> このプロジェクトは非公式ツールの使用に起因するいかなる問題についても責任を負いません。また、**私たちはそれらのOAuthシステムのカスタム実装を一切持っていません。**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -91,8 +113,7 @@
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.2.1 モデル設定](#421-モデル設定)
|
||||
- [4.2.2 oh-my-opencode エージェントモデルのオーバーライド](#422-oh-my-opencode-エージェントモデルのオーバーライド)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [モデル設定](#モデル設定)
|
||||
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [セットアップの確認](#セットアップの確認)
|
||||
- [ユーザーに「おめでとうございます!🎉」と伝える](#ユーザーにおめでとうございますと伝える)
|
||||
@@ -354,37 +375,46 @@ opencode auth login
|
||||
|
||||
**マルチアカウントロードバランシング**: プラグインは最大10個の Google アカウントをサポートします。1つのアカウントがレートリミットに達すると、自動的に次のアカウントに切り替わります。
|
||||
|
||||
#### 4.3 OpenAI (ChatGPT Plus/Pro)
|
||||
#### 4.3 GitHub Copilot(フォールバックプロバイダー)
|
||||
|
||||
まず、opencode-openai-codex-auth プラグインを追加します:
|
||||
GitHub Copilot は、ネイティブプロバイダー(Claude、ChatGPT、Gemini)が利用できない場合の**フォールバックプロバイダー**としてサポートされています。インストーラーは、Copilot をネイティブプロバイダーより低い優先度で構成します。
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.3.0"
|
||||
]
|
||||
}
|
||||
**優先度**: ネイティブプロバイダー (Claude/ChatGPT/Gemini) > GitHub Copilot > 無料モデル
|
||||
|
||||
##### モデルマッピング
|
||||
|
||||
GitHub Copilot が有効な場合、oh-my-opencode は以下のモデル割り当てを使用します:
|
||||
|
||||
| エージェント | モデル |
|
||||
|--------------|--------|
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.5` |
|
||||
| **Oracle** | `github-copilot/gpt-5.2` |
|
||||
| **Explore** | `grok code`(デフォルト) |
|
||||
| **Librarian** | `glm 4.7 free`(デフォルト) |
|
||||
|
||||
GitHub Copilot はプロキシプロバイダーとして機能し、サブスクリプションに基づいて基盤となるモデルにリクエストをルーティングします。
|
||||
|
||||
##### セットアップ
|
||||
|
||||
インストーラーを実行し、GitHub Copilot で「はい」を選択します:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# サブスクリプション(Claude、ChatGPT、Gemini)を選択
|
||||
# プロンプトが表示されたら: "Do you have a GitHub Copilot subscription?" → 「はい」を選択
|
||||
```
|
||||
|
||||
##### モデル設定
|
||||
または、非対話モードを使用します:
|
||||
|
||||
`opencode.json` に完全なモデル設定も構成する必要があります。
|
||||
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)(OpenCode v1.0.210+)または [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧バージョン)から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no --copilot=yes
|
||||
```
|
||||
|
||||
**利用可能なモデル**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
|
||||
|
||||
**Variants** (OpenCode v1.0.210+): `--variant=<none|low|medium|high|xhigh>` オプションで推論強度を制御できます。
|
||||
|
||||
その後、認証を行います:
|
||||
その後、GitHub で認証します:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Provider: OpenAI を選択
|
||||
# Login method: ChatGPT Plus/Pro (Codex Subscription) を選択
|
||||
# ユーザーにブラウザでの OAuth フロー完了を案内
|
||||
# 完了まで待機
|
||||
# 成功を確認し、ユーザーに報告
|
||||
# 選択: GitHub → OAuth 経由で認証
|
||||
```
|
||||
|
||||
|
||||
@@ -518,17 +548,13 @@ Ask @explore for the policy on this feature
|
||||
あなたがエディタで使っているその機能、他のエージェントは触ることができません。
|
||||
最高の同僚に最高の道具を渡してください。これでリファクタリングも、ナビゲーションも、分析も、エージェントが適切に行えるようになります。
|
||||
|
||||
- **lsp_hover**: その位置の型情報、ドキュメント、シグネチャを取得
|
||||
- **lsp_goto_definition**: シンボル定義へジャンプ
|
||||
- **lsp_find_references**: ワークスペース全体で使用箇所を検索
|
||||
- **lsp_document_symbols**: ファイルのシンボルアウトラインを取得
|
||||
- **lsp_workspace_symbols**: プロジェクト全体から名前でシンボルを検索
|
||||
- **lsp_symbols**: ファイルからシンボルを取得 (scope='document') またはワークスペース全体を検索 (scope='workspace')
|
||||
- **lsp_diagnostics**: ビルド前にエラー/警告を取得
|
||||
- **lsp_servers**: 利用可能な LSP サーバー一覧
|
||||
- **lsp_prepare_rename**: 名前変更操作の検証
|
||||
- **lsp_rename**: ワークスペース全体でシンボル名を変更
|
||||
- **lsp_code_actions**: 利用可能なクイックフィックス/リファクタリングを取得
|
||||
- **lsp_code_action_resolve**: コードアクションを適用
|
||||
- **ast_grep_search**: AST 認識コードパターン検索 (25言語対応)
|
||||
- **ast_grep_replace**: AST 認識コード置換
|
||||
|
||||
|
||||
102
README.md
102
README.md
@@ -5,8 +5,8 @@
|
||||
|
||||
> [!TIP]
|
||||
>
|
||||
> [](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.1)
|
||||
> > **The Orchestrator is now available in beta. Use `oh-my-opencode@3.0.0-beta.1` to install it.**
|
||||
> [](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.7)
|
||||
> > **The Orchestrator is now available in beta. Use `oh-my-opencode@3.0.0-beta.7` to install it.**
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
@@ -28,8 +28,29 @@
|
||||
|
||||
> This is coding on steroids—`oh-my-opencode` in action. Run background agents, call specialized agents like oracle, librarian, and frontend engineer. Use crafted LSP/AST tools, curated MCPs, and a full Claude Code compatibility layer.
|
||||
|
||||
# Claude OAuth Access Notice
|
||||
|
||||
**Notice: Do not use expensive models for librarian. This is not only unhelpful to you, but also burdens LLM providers. Use models like Claude Haiku, Gemini Flash, GLM 4.7, or MiniMax instead.**
|
||||
## TL;DR
|
||||
|
||||
> Q. Can I use oh-my-opencode?
|
||||
|
||||
Yes.
|
||||
|
||||
> Q. Can I use it with my Claude Code subscription?
|
||||
|
||||
Yes, technically possible. But I cannot recommend using it.
|
||||
|
||||
## FULL
|
||||
|
||||
> As of January 2026, Anthropic has restricted third-party OAuth access citing ToS violations.
|
||||
>
|
||||
> [**Anthropic has cited this project, oh-my-opencode as justification for blocking opencode.**](https://x.com/thdxr/status/2010149530486911014)
|
||||
>
|
||||
> Indeed, some plugins that spoof Claude Code's oauth request signatures exist in the community.
|
||||
>
|
||||
> These tools may work regardless of technical detectability, but users should be aware of ToS implications, and I personally cannot recommend to use those.
|
||||
>
|
||||
> This project is not responsible for any issues arising from the use of unofficial tools, and **we do not have any custom implementations of those oauth systems.**
|
||||
|
||||
|
||||
<div align="center">
|
||||
@@ -76,6 +97,9 @@
|
||||
|
||||
## Contents
|
||||
|
||||
- [Claude OAuth Access Notice](#claude-oauth-access-notice)
|
||||
- [Reviews](#reviews)
|
||||
- [Contents](#contents)
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [Just Skip Reading This Readme](#just-skip-reading-this-readme)
|
||||
- [It's the Age of Agents](#its-the-age-of-agents)
|
||||
@@ -94,8 +118,9 @@
|
||||
- [Google Gemini (Antigravity OAuth)](#google-gemini-antigravity-oauth)
|
||||
- [Model Configuration](#model-configuration)
|
||||
- [oh-my-opencode Agent Model Override](#oh-my-opencode-agent-model-override)
|
||||
- [OpenAI (ChatGPT Plus/Pro)](#openai-chatgpt-pluspro)
|
||||
- [Model Configuration](#model-configuration-1)
|
||||
- [GitHub Copilot (Fallback Provider)](#github-copilot-fallback-provider)
|
||||
- [Model Mappings](#model-mappings)
|
||||
- [Setup](#setup)
|
||||
- [⚠️ Warning](#️-warning)
|
||||
- [Verify the setup](#verify-the-setup)
|
||||
- [Say 'Congratulations! 🎉' to the user](#say-congratulations--to-the-user)
|
||||
@@ -236,12 +261,14 @@ If you don't want all this, as mentioned, you can just pick and choose specific
|
||||
Run the interactive installer:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# or use npx if bunx doesn't work
|
||||
npx oh-my-opencode install
|
||||
# or with bun
|
||||
bunx 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`
|
||||
> **Note**: The CLI ships with standalone binaries for all major platforms. No runtime (Bun/Node.js) is required for CLI execution after installation.
|
||||
>
|
||||
> **Supported platforms**: macOS (ARM64, x64), Linux (x64, ARM64, Alpine/musl), Windows (x64)
|
||||
|
||||
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
|
||||
|
||||
@@ -381,37 +408,46 @@ opencode auth login
|
||||
|
||||
**Multi-Account Load Balancing**: The plugin supports up to 10 Google accounts. When one account hits rate limits, it automatically switches to the next available account.
|
||||
|
||||
#### OpenAI (ChatGPT Plus/Pro)
|
||||
#### GitHub Copilot (Fallback Provider)
|
||||
|
||||
First, add the opencode-openai-codex-auth plugin:
|
||||
GitHub Copilot is supported as a **fallback provider** when native providers (Claude, ChatGPT, Gemini) are unavailable. The installer configures Copilot with lower priority than native providers.
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.3.0"
|
||||
]
|
||||
}
|
||||
**Priority**: Native providers (Claude/ChatGPT/Gemini) > GitHub Copilot > Free models
|
||||
|
||||
##### Model Mappings
|
||||
|
||||
When GitHub Copilot is enabled, oh-my-opencode uses these model assignments:
|
||||
|
||||
| Agent | Model |
|
||||
| ------------- | -------------------------------- |
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.5` |
|
||||
| **Oracle** | `github-copilot/gpt-5.2` |
|
||||
| **Explore** | `grok code` (default) |
|
||||
| **Librarian** | `glm 4.7 free` (default) |
|
||||
|
||||
GitHub Copilot acts as a proxy provider, routing requests to underlying models based on your subscription.
|
||||
|
||||
##### Setup
|
||||
|
||||
Run the installer and select "Yes" for GitHub Copilot:
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# Select your subscriptions (Claude, ChatGPT, Gemini)
|
||||
# When prompted: "Do you have a GitHub Copilot subscription?" → Select "Yes"
|
||||
```
|
||||
|
||||
##### Model Configuration
|
||||
Or use non-interactive mode:
|
||||
|
||||
You'll also need full model settings in `opencode.json`.
|
||||
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json) (for OpenCode v1.0.210+) or [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json) (for older versions), and merge carefully to avoid breaking the user's existing setup.
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no --copilot=yes
|
||||
```
|
||||
|
||||
**Available models**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
|
||||
|
||||
**Variants** (OpenCode v1.0.210+): Use `--variant=<none|low|medium|high|xhigh>` for reasoning effort control.
|
||||
|
||||
Then authenticate:
|
||||
Then authenticate with GitHub:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# Interactive Terminal: Provider: Select OpenAI
|
||||
# Interactive Terminal: Login method: Select ChatGPT Plus/Pro (Codex Subscription)
|
||||
# Interactive Terminal: Guide user through OAuth flow in browser
|
||||
# Wait for completion
|
||||
# Verify success and confirm with user
|
||||
# Select: GitHub → Authenticate via OAuth
|
||||
```
|
||||
|
||||
|
||||
@@ -541,17 +577,13 @@ Syntax highlighting, autocomplete, refactoring, navigation, analysis—and now a
|
||||
The features in your editor? Other agents can't touch them.
|
||||
Hand your best tools to your best colleagues. Now they can properly refactor, navigate, and analyze.
|
||||
|
||||
- **lsp_hover**: Type info, docs, signatures at position
|
||||
- **lsp_goto_definition**: Jump to symbol definition
|
||||
- **lsp_find_references**: Find all usages across workspace
|
||||
- **lsp_document_symbols**: Get file symbol outline
|
||||
- **lsp_workspace_symbols**: Search symbols by name across project
|
||||
- **lsp_symbols**: Get symbols from file (scope='document') or search across workspace (scope='workspace')
|
||||
- **lsp_diagnostics**: Get errors/warnings before build
|
||||
- **lsp_servers**: List available LSP servers
|
||||
- **lsp_prepare_rename**: Validate rename operation
|
||||
- **lsp_rename**: Rename symbol across workspace
|
||||
- **lsp_code_actions**: Get available quick fixes/refactorings
|
||||
- **lsp_code_action_resolve**: Apply code action
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
- **call_omo_agent**: Spawn specialized explore/librarian agents. Supports `run_in_background` parameter for async execution.
|
||||
|
||||
@@ -28,8 +28,29 @@
|
||||
|
||||
> 这是开挂级别的编程——`oh-my-opencode` 实战效果。运行后台智能体,调用专业智能体如 oracle、librarian 和前端工程师。使用精心设计的 LSP/AST 工具、精选的 MCP,以及完整的 Claude Code 兼容层。
|
||||
|
||||
# Claude OAuth 访问通知
|
||||
|
||||
**注意:请勿为 librarian 使用昂贵的模型。这不仅对你没有帮助,还会增加 LLM 服务商的负担。请使用 Claude Haiku、Gemini Flash、GLM 4.7 或 MiniMax 等模型。**
|
||||
## TL;DR
|
||||
|
||||
> Q. 我可以使用 oh-my-opencode 吗?
|
||||
|
||||
可以。
|
||||
|
||||
> Q. 我可以用 Claude Code 订阅来使用它吗?
|
||||
|
||||
是的,技术上可以。但我不建议使用。
|
||||
|
||||
## 详细说明
|
||||
|
||||
> 自2026年1月起,Anthropic 以违反服务条款为由限制了第三方 OAuth 访问。
|
||||
>
|
||||
> [**Anthropic 将本项目 oh-my-opencode 作为封锁 opencode 的理由。**](https://x.com/thdxr/status/2010149530486911014)
|
||||
>
|
||||
> 事实上,社区中确实存在一些伪造 Claude Code OAuth 请求签名的插件。
|
||||
>
|
||||
> 无论技术上是否可检测,这些工具可能都能正常工作,但用户应注意服务条款的相关影响,我个人不建议使用这些工具。
|
||||
>
|
||||
> 本项目对使用非官方工具产生的任何问题概不负责,**我们没有任何这些 OAuth 系统的自定义实现。**
|
||||
|
||||
|
||||
<div align="center">
|
||||
@@ -93,8 +114,7 @@
|
||||
- [Google Gemini (Antigravity OAuth)](#google-gemini-antigravity-oauth)
|
||||
- [模型配置](#模型配置)
|
||||
- [oh-my-opencode 智能体模型覆盖](#oh-my-opencode-智能体模型覆盖)
|
||||
- [OpenAI (ChatGPT Plus/Pro)](#openai-chatgpt-pluspro)
|
||||
- [模型配置](#模型配置-1)
|
||||
|
||||
- [⚠️ 警告](#️-警告)
|
||||
- [验证安装](#验证安装)
|
||||
- [向用户说 '恭喜!🎉'](#向用户说-恭喜)
|
||||
@@ -232,6 +252,11 @@
|
||||
|
||||
### 面向人类用户
|
||||
|
||||
> **⚠️ 先决条件:需要安装 Bun**
|
||||
>
|
||||
> 此工具**需要系统中已安装 [Bun](https://bun.sh/)** 才能运行。
|
||||
> 即使使用 `npx` 运行安装程序,底层运行时仍依赖于 Bun。
|
||||
|
||||
运行交互式安装程序:
|
||||
|
||||
```bash
|
||||
@@ -380,37 +405,46 @@ opencode auth login
|
||||
|
||||
**多账号负载均衡**:该插件支持最多 10 个 Google 账号。当一个账号达到速率限制时,它会自动切换到下一个可用账号。
|
||||
|
||||
#### OpenAI (ChatGPT Plus/Pro)
|
||||
#### GitHub Copilot(备用提供商)
|
||||
|
||||
首先,添加 opencode-openai-codex-auth 插件:
|
||||
GitHub Copilot 作为**备用提供商**受支持,当原生提供商(Claude、ChatGPT、Gemini)不可用时使用。安装程序将 Copilot 配置为低于原生提供商的优先级。
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.3.0"
|
||||
]
|
||||
}
|
||||
**优先级**:原生提供商 (Claude/ChatGPT/Gemini) > GitHub Copilot > 免费模型
|
||||
|
||||
##### 模型映射
|
||||
|
||||
启用 GitHub Copilot 后,oh-my-opencode 使用以下模型分配:
|
||||
|
||||
| 代理 | 模型 |
|
||||
|------|------|
|
||||
| **Sisyphus** | `github-copilot/claude-opus-4.5` |
|
||||
| **Oracle** | `github-copilot/gpt-5.2` |
|
||||
| **Explore** | `grok code`(默认) |
|
||||
| **Librarian** | `glm 4.7 free`(默认) |
|
||||
|
||||
GitHub Copilot 作为代理提供商,根据你的订阅将请求路由到底层模型。
|
||||
|
||||
##### 设置
|
||||
|
||||
运行安装程序并为 GitHub Copilot 选择"是":
|
||||
|
||||
```bash
|
||||
bunx oh-my-opencode install
|
||||
# 选择你的订阅(Claude、ChatGPT、Gemini)
|
||||
# 出现提示时:"Do you have a GitHub Copilot subscription?" → 选择"是"
|
||||
```
|
||||
|
||||
##### 模型配置
|
||||
或使用非交互模式:
|
||||
|
||||
你还需要在 `opencode.json` 中配置完整的模型设置。
|
||||
阅读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)(适用于 OpenCode v1.0.210+)或 [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(适用于旧版本)复制 provider/models 配置,并仔细合并以避免破坏用户现有的设置。
|
||||
```bash
|
||||
bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no --copilot=yes
|
||||
```
|
||||
|
||||
**可用模型**:`openai/gpt-5.2`、`openai/gpt-5.2-codex`、`openai/gpt-5.1-codex-max`、`openai/gpt-5.1-codex`、`openai/gpt-5.1-codex-mini`、`openai/gpt-5.1`
|
||||
|
||||
**变体**(OpenCode v1.0.210+):使用 `--variant=<none|low|medium|high|xhigh>` 控制推理力度。
|
||||
|
||||
然后进行认证:
|
||||
然后使用 GitHub 进行身份验证:
|
||||
|
||||
```bash
|
||||
opencode auth login
|
||||
# 交互式终端:Provider:选择 OpenAI
|
||||
# 交互式终端:Login method:选择 ChatGPT Plus/Pro (Codex Subscription)
|
||||
# 交互式终端:引导用户在浏览器中完成 OAuth 流程
|
||||
# 等待完成
|
||||
# 验证成功并向用户确认
|
||||
# 选择:GitHub → 通过 OAuth 进行身份验证
|
||||
```
|
||||
|
||||
|
||||
@@ -540,17 +574,13 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
你编辑器中的功能?其他智能体无法触及。
|
||||
把你最好的工具交给你最好的同事。现在它们可以正确地重构、导航和分析。
|
||||
|
||||
- **lsp_hover**:位置处的类型信息、文档、签名
|
||||
- **lsp_goto_definition**:跳转到符号定义
|
||||
- **lsp_find_references**:查找工作区中的所有使用
|
||||
- **lsp_document_symbols**:获取文件符号概览
|
||||
- **lsp_workspace_symbols**:按名称在项目中搜索符号
|
||||
- **lsp_symbols**:从文件获取符号 (scope='document') 或在工作区中搜索 (scope='workspace')
|
||||
- **lsp_diagnostics**:在构建前获取错误/警告
|
||||
- **lsp_servers**:列出可用的 LSP 服务器
|
||||
- **lsp_prepare_rename**:验证重命名操作
|
||||
- **lsp_rename**:在工作区中重命名符号
|
||||
- **lsp_code_actions**:获取可用的快速修复/重构
|
||||
- **lsp_code_action_resolve**:应用代码操作
|
||||
- **ast_grep_search**:AST 感知的代码模式搜索(25 种语言)
|
||||
- **ast_grep_replace**:AST 感知的代码替换
|
||||
- **call_omo_agent**:生成专业的 explore/librarian 智能体。支持 `run_in_background` 参数进行异步执行。
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
"sisyphus-task-retry",
|
||||
"prometheus-md-only",
|
||||
"start-work",
|
||||
"sisyphus-orchestrator"
|
||||
@@ -2181,7 +2182,6 @@
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search"
|
||||
|
||||
80
bin/oh-my-opencode.js
Normal file
80
bin/oh-my-opencode.js
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
// bin/oh-my-opencode.js
|
||||
// Wrapper script that detects platform and spawns the correct binary
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Detect libc family on Linux
|
||||
* @returns {string | null} 'glibc', 'musl', or null if detection fails
|
||||
*/
|
||||
function getLibcFamily() {
|
||||
if (process.platform !== "linux") {
|
||||
return undefined; // Not needed on non-Linux
|
||||
}
|
||||
|
||||
try {
|
||||
const detectLibc = require("detect-libc");
|
||||
return detectLibc.familySync();
|
||||
} catch {
|
||||
// detect-libc not available
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
|
||||
// Get platform package name
|
||||
let pkg;
|
||||
try {
|
||||
pkg = getPlatformPackage({ platform, arch, libcFamily });
|
||||
} catch (error) {
|
||||
console.error(`\noh-my-opencode: ${error.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Resolve binary path
|
||||
const binRelPath = getBinaryPath(pkg, platform);
|
||||
|
||||
let binPath;
|
||||
try {
|
||||
binPath = require.resolve(binRelPath);
|
||||
} catch {
|
||||
console.error(`\noh-my-opencode: Platform binary not installed.`);
|
||||
console.error(`\nYour platform: ${platform}-${arch}${libcFamily === "musl" ? "-musl" : ""}`);
|
||||
console.error(`Expected package: ${pkg}`);
|
||||
console.error(`\nTo fix, run:`);
|
||||
console.error(` npm install ${pkg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Spawn the binary
|
||||
const result = spawnSync(binPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
// Handle spawn errors
|
||||
if (result.error) {
|
||||
console.error(`\noh-my-opencode: Failed to execute binary.`);
|
||||
console.error(`Error: ${result.error.message}\n`);
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
// Handle signals
|
||||
if (result.signal) {
|
||||
const signalNum = result.signal === "SIGTERM" ? 15 :
|
||||
result.signal === "SIGKILL" ? 9 :
|
||||
result.signal === "SIGINT" ? 2 : 1;
|
||||
process.exit(128 + signalNum);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
main();
|
||||
38
bin/platform.js
Normal file
38
bin/platform.js
Normal file
@@ -0,0 +1,38 @@
|
||||
// bin/platform.js
|
||||
// Shared platform detection module - used by wrapper and postinstall
|
||||
|
||||
/**
|
||||
* Get the platform-specific package name
|
||||
* @param {{ platform: string, arch: string, libcFamily?: string | null }} options
|
||||
* @returns {string} Package name like "oh-my-opencode-darwin-arm64"
|
||||
* @throws {Error} If libc cannot be detected on Linux
|
||||
*/
|
||||
export function getPlatformPackage({ platform, arch, libcFamily }) {
|
||||
let suffix = "";
|
||||
if (platform === "linux") {
|
||||
if (libcFamily === null || libcFamily === undefined) {
|
||||
throw new Error(
|
||||
"Could not detect libc on Linux. " +
|
||||
"Please ensure detect-libc is installed or report this issue."
|
||||
);
|
||||
}
|
||||
if (libcFamily === "musl") {
|
||||
suffix = "-musl";
|
||||
}
|
||||
}
|
||||
|
||||
// Map platform names: win32 -> windows (for package name)
|
||||
const os = platform === "win32" ? "windows" : platform;
|
||||
return `oh-my-opencode-${os}-${arch}${suffix}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the binary within a platform package
|
||||
* @param {string} pkg Package name
|
||||
* @param {string} platform Process platform
|
||||
* @returns {string} Relative path like "oh-my-opencode-darwin-arm64/bin/oh-my-opencode"
|
||||
*/
|
||||
export function getBinaryPath(pkg, platform) {
|
||||
const ext = platform === "win32" ? ".exe" : "";
|
||||
return `${pkg}/bin/oh-my-opencode${ext}`;
|
||||
}
|
||||
148
bin/platform.test.ts
Normal file
148
bin/platform.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
// bin/platform.test.ts
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getPlatformPackage, getBinaryPath } from "./platform.js";
|
||||
|
||||
describe("getPlatformPackage", () => {
|
||||
// #region Darwin platforms
|
||||
test("returns darwin-arm64 for macOS ARM64", () => {
|
||||
// #given macOS ARM64 platform
|
||||
const input = { platform: "darwin", arch: "arm64" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-darwin-arm64");
|
||||
});
|
||||
|
||||
test("returns darwin-x64 for macOS Intel", () => {
|
||||
// #given macOS x64 platform
|
||||
const input = { platform: "darwin", arch: "x64" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-darwin-x64");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Linux glibc platforms
|
||||
test("returns linux-x64 for Linux x64 with glibc", () => {
|
||||
// #given Linux x64 with glibc
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: "glibc" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-linux-x64");
|
||||
});
|
||||
|
||||
test("returns linux-arm64 for Linux ARM64 with glibc", () => {
|
||||
// #given Linux ARM64 with glibc
|
||||
const input = { platform: "linux", arch: "arm64", libcFamily: "glibc" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name
|
||||
expect(result).toBe("oh-my-opencode-linux-arm64");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Linux musl platforms
|
||||
test("returns linux-x64-musl for Alpine x64", () => {
|
||||
// #given Linux x64 with musl (Alpine)
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: "musl" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name with musl suffix
|
||||
expect(result).toBe("oh-my-opencode-linux-x64-musl");
|
||||
});
|
||||
|
||||
test("returns linux-arm64-musl for Alpine ARM64", () => {
|
||||
// #given Linux ARM64 with musl (Alpine)
|
||||
const input = { platform: "linux", arch: "arm64", libcFamily: "musl" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name with musl suffix
|
||||
expect(result).toBe("oh-my-opencode-linux-arm64-musl");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Windows platform
|
||||
test("returns windows-x64 for Windows", () => {
|
||||
// #given Windows x64 platform (win32 is Node's platform name)
|
||||
const input = { platform: "win32", arch: "x64" };
|
||||
|
||||
// #when getting platform package
|
||||
const result = getPlatformPackage(input);
|
||||
|
||||
// #then returns correct package name with 'windows' not 'win32'
|
||||
expect(result).toBe("oh-my-opencode-windows-x64");
|
||||
});
|
||||
// #endregion
|
||||
|
||||
// #region Error cases
|
||||
test("throws error for Linux with null libcFamily", () => {
|
||||
// #given Linux platform with null libc detection
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: null };
|
||||
|
||||
// #when getting platform package
|
||||
// #then throws descriptive error
|
||||
expect(() => getPlatformPackage(input)).toThrow("Could not detect libc");
|
||||
});
|
||||
|
||||
test("throws error for Linux with undefined libcFamily", () => {
|
||||
// #given Linux platform with undefined libc
|
||||
const input = { platform: "linux", arch: "x64", libcFamily: undefined };
|
||||
|
||||
// #when getting platform package
|
||||
// #then throws descriptive error
|
||||
expect(() => getPlatformPackage(input)).toThrow("Could not detect libc");
|
||||
});
|
||||
// #endregion
|
||||
});
|
||||
|
||||
describe("getBinaryPath", () => {
|
||||
test("returns path without .exe for Unix platforms", () => {
|
||||
// #given Unix platform package
|
||||
const pkg = "oh-my-opencode-darwin-arm64";
|
||||
const platform = "darwin";
|
||||
|
||||
// #when getting binary path
|
||||
const result = getBinaryPath(pkg, platform);
|
||||
|
||||
// #then returns path without extension
|
||||
expect(result).toBe("oh-my-opencode-darwin-arm64/bin/oh-my-opencode");
|
||||
});
|
||||
|
||||
test("returns path with .exe for Windows", () => {
|
||||
// #given Windows platform package
|
||||
const pkg = "oh-my-opencode-windows-x64";
|
||||
const platform = "win32";
|
||||
|
||||
// #when getting binary path
|
||||
const result = getBinaryPath(pkg, platform);
|
||||
|
||||
// #then returns path with .exe extension
|
||||
expect(result).toBe("oh-my-opencode-windows-x64/bin/oh-my-opencode.exe");
|
||||
});
|
||||
|
||||
test("returns path without .exe for Linux", () => {
|
||||
// #given Linux platform package
|
||||
const pkg = "oh-my-opencode-linux-x64";
|
||||
const platform = "linux";
|
||||
|
||||
// #when getting binary path
|
||||
const result = getBinaryPath(pkg, platform);
|
||||
|
||||
// #then returns path without extension
|
||||
expect(result).toBe("oh-my-opencode-linux-x64/bin/oh-my-opencode");
|
||||
});
|
||||
});
|
||||
18
bun.lock
18
bun.lock
@@ -11,9 +11,10 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"@opencode-ai/plugin": "^1.1.19",
|
||||
"@opencode-ai/sdk": "^1.1.19",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
@@ -29,6 +30,15 @@
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "0.0.0",
|
||||
"oh-my-opencode-darwin-x64": "0.0.0",
|
||||
"oh-my-opencode-linux-arm64": "0.0.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "0.0.0",
|
||||
"oh-my-opencode-linux-x64": "0.0.0",
|
||||
"oh-my-opencode-linux-x64-musl": "0.0.0",
|
||||
"oh-my-opencode-windows-x64": "0.0.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"trustedDependencies": [
|
||||
@@ -85,9 +95,9 @@
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.1", "", { "dependencies": { "@opencode-ai/sdk": "1.1.1", "zod": "4.1.8" } }, "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.19", "", { "dependencies": { "@opencode-ai/sdk": "1.1.19", "zod": "4.1.8" } }, "sha512-Q6qBEjHb/dJMEw4BUqQxEswTMxCCHUpFMMb6jR8HTTs8X/28XRkKt5pHNPA82GU65IlSoPRph+zd8LReBDN53Q=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.19", "", {}, "sha512-XhZhFuvlLCqDpvNtUEjOsi/wvFj3YCXb1dySp+OONQRMuHlorNYnNa7P2A2ntKuhRdGT1Xt5na0nFzlUyNw+4A=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
25
package.json
25
package.json
@@ -1,15 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.0.0-beta.6",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"oh-my-opencode": "./dist/cli/index.js"
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.js"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"bin",
|
||||
"postinstall.mjs"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -20,8 +22,11 @@
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi && tsc --emitDeclarationOnly && bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi && bun run build:schema",
|
||||
"build:all": "bun run build && bun run build:binaries",
|
||||
"build:binaries": "bun run script/build-binaries.ts",
|
||||
"build:schema": "bun run script/build-schema.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"postinstall": "node postinstall.mjs",
|
||||
"prepublishOnly": "bun run clean && bun run build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "bun test"
|
||||
@@ -52,9 +57,10 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"@opencode-ai/plugin": "^1.1.19",
|
||||
"@opencode-ai/sdk": "^1.1.19",
|
||||
"commander": "^14.0.2",
|
||||
"detect-libc": "^2.0.0",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
@@ -70,6 +76,15 @@
|
||||
"bun-types": "latest",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-darwin-x64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-arm64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-x64": "3.0.0-beta.8",
|
||||
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.8",
|
||||
"oh-my-opencode-windows-x64": "3.0.0-beta.8"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi",
|
||||
|
||||
0
packages/darwin-arm64/bin/.gitkeep
Normal file
0
packages/darwin-arm64/bin/.gitkeep
Normal file
16
packages/darwin-arm64/package.json
Normal file
16
packages/darwin-arm64/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-arm64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/darwin-x64/bin/.gitkeep
Normal file
0
packages/darwin-x64/bin/.gitkeep
Normal file
16
packages/darwin-x64/package.json
Normal file
16
packages/darwin-x64/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oh-my-opencode-darwin-x64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["darwin"],
|
||||
"cpu": ["x64"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-arm64-musl/bin/.gitkeep
Normal file
0
packages/linux-arm64-musl/bin/.gitkeep
Normal file
17
packages/linux-arm64-musl/package.json
Normal file
17
packages/linux-arm64-musl/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64-musl",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"libc": ["musl"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-arm64/bin/.gitkeep
Normal file
0
packages/linux-arm64/bin/.gitkeep
Normal file
17
packages/linux-arm64/package.json
Normal file
17
packages/linux-arm64/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-arm64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"libc": ["glibc"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-x64-musl/bin/.gitkeep
Normal file
0
packages/linux-x64-musl/bin/.gitkeep
Normal file
17
packages/linux-x64-musl/package.json
Normal file
17
packages/linux-x64-musl/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64-musl",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"libc": ["musl"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/linux-x64/bin/.gitkeep
Normal file
0
packages/linux-x64/bin/.gitkeep
Normal file
17
packages/linux-x64/package.json
Normal file
17
packages/linux-x64/package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oh-my-opencode-linux-x64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"libc": ["glibc"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode"
|
||||
}
|
||||
}
|
||||
0
packages/windows-x64/bin/.gitkeep
Normal file
0
packages/windows-x64/bin/.gitkeep
Normal file
16
packages/windows-x64/package.json
Normal file
16
packages/windows-x64/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oh-my-opencode-windows-x64",
|
||||
"version": "3.0.0-beta.8",
|
||||
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/code-yeongyu/oh-my-opencode"
|
||||
},
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"],
|
||||
"files": ["bin"],
|
||||
"bin": {
|
||||
"oh-my-opencode": "./bin/oh-my-opencode.exe"
|
||||
}
|
||||
}
|
||||
43
postinstall.mjs
Normal file
43
postinstall.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
// postinstall.mjs
|
||||
// Runs after npm install to verify platform binary is available
|
||||
|
||||
import { createRequire } from "node:module";
|
||||
import { getPlatformPackage, getBinaryPath } from "./bin/platform.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
/**
|
||||
* Detect libc family on Linux
|
||||
*/
|
||||
function getLibcFamily() {
|
||||
if (process.platform !== "linux") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const detectLibc = require("detect-libc");
|
||||
return detectLibc.familySync();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const { platform, arch } = process;
|
||||
const libcFamily = getLibcFamily();
|
||||
|
||||
try {
|
||||
const pkg = getPlatformPackage({ platform, arch, libcFamily });
|
||||
const binPath = getBinaryPath(pkg, platform);
|
||||
|
||||
// Try to resolve the binary
|
||||
require.resolve(binPath);
|
||||
console.log(`✓ oh-my-opencode binary installed for ${platform}-${arch}`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠ oh-my-opencode: ${error.message}`);
|
||||
console.warn(` The CLI may not work on this platform.`);
|
||||
// Don't fail installation - let user try anyway
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
103
script/build-binaries.ts
Normal file
103
script/build-binaries.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env bun
|
||||
// script/build-binaries.ts
|
||||
// Build platform-specific binaries for CLI distribution
|
||||
|
||||
import { $ } from "bun";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
interface PlatformTarget {
|
||||
dir: string;
|
||||
target: string;
|
||||
binary: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const PLATFORMS: PlatformTarget[] = [
|
||||
{ dir: "darwin-arm64", target: "bun-darwin-arm64", binary: "oh-my-opencode", description: "macOS ARM64" },
|
||||
{ dir: "darwin-x64", target: "bun-darwin-x64", binary: "oh-my-opencode", description: "macOS x64" },
|
||||
{ dir: "linux-x64", target: "bun-linux-x64", binary: "oh-my-opencode", description: "Linux x64 (glibc)" },
|
||||
{ dir: "linux-arm64", target: "bun-linux-arm64", binary: "oh-my-opencode", description: "Linux ARM64 (glibc)" },
|
||||
{ dir: "linux-x64-musl", target: "bun-linux-x64-musl", binary: "oh-my-opencode", description: "Linux x64 (musl)" },
|
||||
{ dir: "linux-arm64-musl", target: "bun-linux-arm64-musl", binary: "oh-my-opencode", description: "Linux ARM64 (musl)" },
|
||||
{ dir: "windows-x64", target: "bun-windows-x64", binary: "oh-my-opencode.exe", description: "Windows x64" },
|
||||
];
|
||||
|
||||
const ENTRY_POINT = "src/cli/index.ts";
|
||||
|
||||
async function buildPlatform(platform: PlatformTarget): Promise<boolean> {
|
||||
const outfile = join("packages", platform.dir, "bin", platform.binary);
|
||||
|
||||
console.log(`\n📦 Building ${platform.description}...`);
|
||||
console.log(` Target: ${platform.target}`);
|
||||
console.log(` Output: ${outfile}`);
|
||||
|
||||
try {
|
||||
await $`bun build --compile --minify --sourcemap --bytecode --target=${platform.target} ${ENTRY_POINT} --outfile=${outfile}`;
|
||||
|
||||
// Verify binary exists
|
||||
if (!existsSync(outfile)) {
|
||||
console.error(` ❌ Binary not found after build: ${outfile}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify binary with file command (skip on Windows host for non-Windows targets)
|
||||
if (process.platform !== "win32") {
|
||||
const fileInfo = await $`file ${outfile}`.text();
|
||||
console.log(` ✓ ${fileInfo.trim()}`);
|
||||
} else {
|
||||
console.log(` ✓ Binary created successfully`);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(` ❌ Build failed: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("🔨 Building oh-my-opencode platform binaries");
|
||||
console.log(` Entry point: ${ENTRY_POINT}`);
|
||||
console.log(` Platforms: ${PLATFORMS.length}`);
|
||||
|
||||
// Verify entry point exists
|
||||
if (!existsSync(ENTRY_POINT)) {
|
||||
console.error(`\n❌ Entry point not found: ${ENTRY_POINT}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const results: { platform: string; success: boolean }[] = [];
|
||||
|
||||
for (const platform of PLATFORMS) {
|
||||
const success = await buildPlatform(platform);
|
||||
results.push({ platform: platform.description, success });
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log("\n" + "=".repeat(50));
|
||||
console.log("Build Summary:");
|
||||
console.log("=".repeat(50));
|
||||
|
||||
const succeeded = results.filter(r => r.success).length;
|
||||
const failed = results.filter(r => !r.success).length;
|
||||
|
||||
for (const result of results) {
|
||||
const icon = result.success ? "✓" : "✗";
|
||||
console.log(` ${icon} ${result.platform}`);
|
||||
}
|
||||
|
||||
console.log("=".repeat(50));
|
||||
console.log(`Total: ${succeeded} succeeded, ${failed} failed`);
|
||||
|
||||
if (failed > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\n✅ All platform binaries built successfully!\n");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("Fatal error:", error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,12 +1,24 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { $ } from "bun"
|
||||
import { existsSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
const bump = process.env.BUMP as "major" | "minor" | "patch" | undefined
|
||||
const versionOverride = process.env.VERSION
|
||||
|
||||
console.log("=== Publishing oh-my-opencode ===\n")
|
||||
const PLATFORM_PACKAGES = [
|
||||
"darwin-arm64",
|
||||
"darwin-x64",
|
||||
"linux-x64",
|
||||
"linux-arm64",
|
||||
"linux-x64-musl",
|
||||
"linux-arm64-musl",
|
||||
"windows-x64",
|
||||
]
|
||||
|
||||
console.log("=== Publishing oh-my-opencode (multi-package) ===\n")
|
||||
|
||||
async function fetchPreviousVersion(): Promise<string> {
|
||||
try {
|
||||
@@ -22,7 +34,9 @@ async function fetchPreviousVersion(): Promise<string> {
|
||||
}
|
||||
|
||||
function bumpVersion(version: string, type: "major" | "minor" | "patch"): string {
|
||||
const [major, minor, patch] = version.split(".").map(Number)
|
||||
// Handle prerelease versions (e.g., 3.0.0-beta.7)
|
||||
const baseVersion = version.split("-")[0]
|
||||
const [major, minor, patch] = baseVersion.split(".").map(Number)
|
||||
switch (type) {
|
||||
case "major":
|
||||
return `${major + 1}.0.0`
|
||||
@@ -33,14 +47,42 @@ function bumpVersion(version: string, type: "major" | "minor" | "patch"): string
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePackageVersion(newVersion: string): Promise<void> {
|
||||
const pkgPath = new URL("../package.json", import.meta.url).pathname
|
||||
async function updatePackageVersion(pkgPath: string, newVersion: string): Promise<void> {
|
||||
let pkg = await Bun.file(pkgPath).text()
|
||||
pkg = pkg.replace(/"version": "[^"]+"/, `"version": "${newVersion}"`)
|
||||
await Bun.file(pkgPath).write(pkg)
|
||||
await Bun.write(pkgPath, pkg)
|
||||
console.log(`Updated: ${pkgPath}`)
|
||||
}
|
||||
|
||||
async function updateAllPackageVersions(newVersion: string): Promise<void> {
|
||||
console.log("\nSyncing version across all packages...")
|
||||
|
||||
// Update main package.json
|
||||
const mainPkgPath = new URL("../package.json", import.meta.url).pathname
|
||||
await updatePackageVersion(mainPkgPath, newVersion)
|
||||
|
||||
// Update optionalDependencies versions in main package.json
|
||||
let mainPkg = await Bun.file(mainPkgPath).text()
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
const pkgName = `oh-my-opencode-${platform}`
|
||||
mainPkg = mainPkg.replace(
|
||||
new RegExp(`"${pkgName}": "[^"]+"`),
|
||||
`"${pkgName}": "${newVersion}"`
|
||||
)
|
||||
}
|
||||
await Bun.write(mainPkgPath, mainPkg)
|
||||
|
||||
// Update each platform package.json
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
const pkgPath = new URL(`../packages/${platform}/package.json`, import.meta.url).pathname
|
||||
if (existsSync(pkgPath)) {
|
||||
await updatePackageVersion(pkgPath, newVersion)
|
||||
} else {
|
||||
console.warn(`Warning: ${pkgPath} not found`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function generateChangelog(previous: string): Promise<string[]> {
|
||||
const notes: string[] = []
|
||||
|
||||
@@ -113,28 +155,101 @@ function getDistTag(version: string): string | null {
|
||||
return tag || "next"
|
||||
}
|
||||
|
||||
async function buildAndPublish(version: string): Promise<void> {
|
||||
console.log("\nBuilding before publish...")
|
||||
await $`bun run clean && bun run build`
|
||||
interface PublishResult {
|
||||
success: boolean
|
||||
alreadyPublished?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
console.log("\nPublishing to npm...")
|
||||
const distTag = getDistTag(version)
|
||||
async function publishPackage(cwd: string, distTag: string | null): Promise<PublishResult> {
|
||||
const tagArgs = distTag ? ["--tag", distTag] : []
|
||||
const provenanceArgs = process.env.CI ? ["--provenance"] : []
|
||||
|
||||
if (process.env.CI) {
|
||||
await $`npm publish --access public --provenance --ignore-scripts ${tagArgs}`
|
||||
} else {
|
||||
await $`npm publish --access public --ignore-scripts ${tagArgs}`
|
||||
try {
|
||||
await $`npm publish --access public --ignore-scripts ${provenanceArgs} ${tagArgs}`.cwd(cwd)
|
||||
return { success: true }
|
||||
} catch (error: any) {
|
||||
const stderr = error?.stderr?.toString() || error?.message || ""
|
||||
|
||||
// E409 = version already exists (idempotent success)
|
||||
if (
|
||||
stderr.includes("EPUBLISHCONFLICT") ||
|
||||
stderr.includes("E409") ||
|
||||
stderr.includes("cannot publish over") ||
|
||||
stderr.includes("already exists")
|
||||
) {
|
||||
return { success: true, alreadyPublished: true }
|
||||
}
|
||||
|
||||
return { success: false, error: stderr }
|
||||
}
|
||||
}
|
||||
|
||||
async function publishAllPackages(version: string): Promise<void> {
|
||||
const distTag = getDistTag(version)
|
||||
const skipPlatform = process.env.SKIP_PLATFORM_PACKAGES === "true"
|
||||
|
||||
if (skipPlatform) {
|
||||
console.log("\n⏭️ Skipping platform packages (SKIP_PLATFORM_PACKAGES=true)")
|
||||
} else {
|
||||
console.log("\n📦 Publishing platform packages...")
|
||||
|
||||
// Publish platform packages first
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
const pkgDir = join(process.cwd(), "packages", platform)
|
||||
const pkgName = `oh-my-opencode-${platform}`
|
||||
|
||||
console.log(`\n Publishing ${pkgName}...`)
|
||||
const result = await publishPackage(pkgDir, distTag)
|
||||
|
||||
if (result.success) {
|
||||
if (result.alreadyPublished) {
|
||||
console.log(` ✓ ${pkgName}@${version} (already published)`)
|
||||
} else {
|
||||
console.log(` ✓ ${pkgName}@${version}`)
|
||||
}
|
||||
} else {
|
||||
console.error(` ✗ ${pkgName} failed: ${result.error}`)
|
||||
throw new Error(`Failed to publish ${pkgName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish main package last
|
||||
console.log(`\n📦 Publishing main package...`)
|
||||
const mainResult = await publishPackage(process.cwd(), distTag)
|
||||
|
||||
if (mainResult.success) {
|
||||
if (mainResult.alreadyPublished) {
|
||||
console.log(` ✓ ${PACKAGE_NAME}@${version} (already published)`)
|
||||
} else {
|
||||
console.log(` ✓ ${PACKAGE_NAME}@${version}`)
|
||||
}
|
||||
} else {
|
||||
console.error(` ✗ ${PACKAGE_NAME} failed: ${mainResult.error}`)
|
||||
throw new Error(`Failed to publish ${PACKAGE_NAME}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function buildPackages(): Promise<void> {
|
||||
console.log("\nBuilding packages...")
|
||||
await $`bun run clean && bun run build`
|
||||
console.log("Building platform binaries...")
|
||||
await $`bun run build:binaries`
|
||||
}
|
||||
|
||||
async function gitTagAndRelease(newVersion: string, notes: string[]): Promise<void> {
|
||||
if (!process.env.CI) return
|
||||
|
||||
console.log("\nCommitting and tagging...")
|
||||
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
|
||||
await $`git config user.name "github-actions[bot]"`
|
||||
|
||||
// Add all package.json files
|
||||
await $`git add package.json assets/oh-my-opencode.schema.json`
|
||||
for (const platform of PLATFORM_PACKAGES) {
|
||||
await $`git add packages/${platform}/package.json`.nothrow()
|
||||
}
|
||||
|
||||
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
|
||||
if (hasStagedChanges.exitCode !== 0) {
|
||||
@@ -181,15 +296,16 @@ async function main() {
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await updatePackageVersion(newVersion)
|
||||
await updateAllPackageVersions(newVersion)
|
||||
const changelog = await generateChangelog(previous)
|
||||
const contributors = await getContributors(previous)
|
||||
const notes = [...changelog, ...contributors]
|
||||
|
||||
await buildAndPublish(newVersion)
|
||||
await buildPackages()
|
||||
await publishAllPackages(newVersion)
|
||||
await gitTagAndRelease(newVersion, notes)
|
||||
|
||||
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} ===`)
|
||||
console.log(`\n=== Successfully published ${PACKAGE_NAME}@${newVersion} (8 packages) ===`)
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -495,6 +495,62 @@
|
||||
"created_at": "2026-01-14T01:57:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 760
|
||||
},
|
||||
{
|
||||
"name": "0Jaeyoung0",
|
||||
"id": 67817265,
|
||||
"comment_id": 3747909072,
|
||||
"created_at": "2026-01-14T05:56:13Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 774
|
||||
},
|
||||
{
|
||||
"name": "MotorwaySouth9",
|
||||
"id": 205539026,
|
||||
"comment_id": 3748060487,
|
||||
"created_at": "2026-01-14T06:50:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 776
|
||||
},
|
||||
{
|
||||
"name": "dang232",
|
||||
"id": 92773067,
|
||||
"comment_id": 3748235411,
|
||||
"created_at": "2026-01-14T07:41:50Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 777
|
||||
},
|
||||
{
|
||||
"name": "devkade",
|
||||
"id": 64977390,
|
||||
"comment_id": 3749807159,
|
||||
"created_at": "2026-01-14T14:25:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 784
|
||||
},
|
||||
{
|
||||
"name": "stranger2904",
|
||||
"id": 57737909,
|
||||
"comment_id": 3750612223,
|
||||
"created_at": "2026-01-14T17:06:12Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 788
|
||||
},
|
||||
{
|
||||
"name": "stranger29",
|
||||
"id": 29339256,
|
||||
"comment_id": 3751601362,
|
||||
"created_at": "2026-01-14T20:31:35Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 795
|
||||
},
|
||||
{
|
||||
"name": "mmlmt2604",
|
||||
"id": 59196850,
|
||||
"comment_id": 3753859484,
|
||||
"created_at": "2026-01-15T09:57:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 812
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,20 +6,21 @@ AI agent definitions for multi-model orchestration, delegating tasks to speciali
|
||||
## STRUCTURE
|
||||
```
|
||||
agents/
|
||||
├── orchestrator-sisyphus.ts # Orchestrator agent (1486 lines) - 7-section delegation, wisdom
|
||||
├── orchestrator-sisyphus.ts # Orchestrator agent (1485 lines) - 7-section delegation, wisdom
|
||||
├── sisyphus.ts # Main Sisyphus prompt (643 lines)
|
||||
├── sisyphus-junior.ts # Junior variant for delegated tasks
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (GLM-4.7-free)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Pro)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro Preview)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Pro Preview)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── prometheus-prompt.ts # Planning agent prompt (988 lines) - interview mode
|
||||
├── prometheus-prompt.ts # Planning agent prompt (991 lines) - interview mode
|
||||
├── metis.ts # Plan Consultant agent - pre-planning analysis
|
||||
├── momus.ts # Plan Reviewer agent - plan validation
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── sisyphus-prompt-builder.ts # Factory for orchestrator prompts
|
||||
├── types.ts # AgentModelConfig interface
|
||||
├── utils.ts # createBuiltinAgents(), getAgentName()
|
||||
└── index.ts # builtinAgents export
|
||||
@@ -28,15 +29,15 @@ agents/
|
||||
## AGENT MODELS
|
||||
| Agent | Default Model | Purpose |
|
||||
|-------|---------------|---------|
|
||||
| Sisyphus | claude-opus-4-5 | Primary orchestrator. 32k extended thinking budget. |
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator. 32k extended thinking budget. |
|
||||
| oracle | openai/gpt-5.2 | High-IQ debugging, architecture, strategic consultation. |
|
||||
| librarian | glm-4.7-free | Multi-repo analysis, docs research, GitHub examples. |
|
||||
| explore | grok-code | Fast contextual grep. Fallbacks: Gemini-3-Flash, Haiku-4-5. |
|
||||
| frontend-ui-ux | gemini-3-pro | Production-grade UI/UX generation and styling. |
|
||||
| document-writer | gemini-3-pro | Technical writing, guides, API documentation. |
|
||||
| Prometheus | claude-opus-4-5 | Strategic planner. Interview mode, orchestrates Metis/Momus. |
|
||||
| Metis | claude-sonnet-4-5 | Plan Consultant. Pre-planning risk/requirement analysis. |
|
||||
| Momus | claude-sonnet-4-5 | Plan Reviewer. Validation and quality enforcement. |
|
||||
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs research, GitHub examples. |
|
||||
| explore | opencode/grok-code | Fast contextual grep. Fallbacks: Gemini-3-Flash, Haiku-4-5. |
|
||||
| frontend-ui-ux | google/gemini-3-pro-preview | Production-grade UI/UX generation and styling. |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical writing, guides, API documentation. |
|
||||
| Prometheus | anthropic/claude-opus-4-5 | Strategic planner. Interview mode, orchestrates Metis/Momus. |
|
||||
| Metis | anthropic/claude-sonnet-4-5 | Plan Consultant. Pre-planning risk/requirement analysis. |
|
||||
| Momus | anthropic/claude-sonnet-4-5 | Plan Reviewer. Validation and quality enforcement. |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
1. Create `src/agents/my-agent.ts` exporting `AgentConfig`.
|
||||
|
||||
@@ -1449,6 +1449,7 @@ export function createOrchestratorSisyphusAgent(ctx?: OrchestratorContext): Agen
|
||||
temperature: 0.1,
|
||||
prompt: buildDynamicOrchestratorPrompt(ctx),
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
color: "#10B981",
|
||||
...restrictions,
|
||||
} as AgentConfig
|
||||
}
|
||||
|
||||
@@ -479,6 +479,7 @@ sisyphus_task(agent="librarian", prompt="Find open source implementations of [fe
|
||||
- Maintain conversational tone
|
||||
- Use gathered evidence to inform suggestions
|
||||
- Ask questions that help user articulate needs
|
||||
- **Use the \`Question\` tool when presenting multiple options** (structured UI for selection)
|
||||
- Confirm understanding before proceeding
|
||||
- **Update draft file after EVERY meaningful exchange** (see Rule 6)
|
||||
|
||||
|
||||
@@ -84,13 +84,14 @@ export const SISYPHUS_JUNIOR_DEFAULTS = {
|
||||
} as const
|
||||
|
||||
export function createSisyphusJuniorAgentWithOverrides(
|
||||
override: AgentOverrideConfig | undefined
|
||||
override: AgentOverrideConfig | undefined,
|
||||
systemDefaultModel?: string
|
||||
): AgentConfig {
|
||||
if (override?.disable) {
|
||||
override = undefined
|
||||
}
|
||||
|
||||
const model = override?.model ?? SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
|
||||
|
||||
const promptAppend = override?.prompt_append
|
||||
|
||||
@@ -206,28 +206,55 @@ export function buildFrontendSection(agents: AvailableAgent[]): string {
|
||||
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
|
||||
if (!frontendAgent) return ""
|
||||
|
||||
return `### Frontend Files: Decision Gate (NOT a blind block)
|
||||
return `### Frontend Files: VISUAL = HARD BLOCK (zero tolerance)
|
||||
|
||||
Frontend files (.tsx, .jsx, .vue, .svelte, .css, etc.) require **classification before action**.
|
||||
**DEFAULT ASSUMPTION**: Any frontend file change is VISUAL until proven otherwise.
|
||||
|
||||
#### Step 1: Classify the Change Type
|
||||
#### HARD BLOCK: Visual Changes (NEVER touch directly)
|
||||
|
||||
| Change Type | Examples | Action |
|
||||
|-------------|----------|--------|
|
||||
| **Visual/UI/UX** | Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images | **DELEGATE** to \`frontend-ui-ux-engineer\` |
|
||||
| **Pure Logic** | API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic | **CAN handle directly** |
|
||||
| **Mixed** | Component changes both visual AND logic | **Split**: handle logic yourself, delegate visual to \`frontend-ui-ux-engineer\` |
|
||||
| Pattern | Action | No Exceptions |
|
||||
|---------|--------|---------------|
|
||||
| \`.tsx\`, \`.jsx\` with styling | DELEGATE | Even "just add className" |
|
||||
| \`.vue\`, \`.svelte\` | DELEGATE | Even single prop change |
|
||||
| \`.css\`, \`.scss\`, \`.sass\`, \`.less\` | DELEGATE | Even color/margin tweak |
|
||||
| Any file with visual keywords | DELEGATE | See keyword list below |
|
||||
|
||||
#### Step 2: Ask Yourself
|
||||
#### Keyword Detection (INSTANT DELEGATE)
|
||||
|
||||
Before touching any frontend file, think:
|
||||
> "Is this change about **how it LOOKS** or **how it WORKS**?"
|
||||
If your change involves **ANY** of these keywords → **STOP. DELEGATE.**
|
||||
|
||||
- **LOOKS** (colors, sizes, positions, animations) → DELEGATE
|
||||
- **WORKS** (data flow, API integration, state) → Handle directly
|
||||
\`\`\`
|
||||
style, className, tailwind, css, color, background, border, shadow,
|
||||
margin, padding, width, height, flex, grid, animation, transition,
|
||||
hover, responsive, font-size, font-weight, icon, svg, image, layout,
|
||||
position, display, opacity, z-index, transform, gradient, theme
|
||||
\`\`\`
|
||||
|
||||
#### When in Doubt → DELEGATE if ANY of these keywords involved:
|
||||
style, className, tailwind, color, background, border, shadow, margin, padding, width, height, flex, grid, animation, transition, hover, responsive, font-size, icon, svg`
|
||||
**YOU CANNOT**:
|
||||
- "Just quickly fix this style"
|
||||
- "It's only one className"
|
||||
- "Too simple to delegate"
|
||||
|
||||
#### EXCEPTION: Pure Logic Only
|
||||
|
||||
You MAY handle directly **ONLY IF ALL** conditions are met:
|
||||
1. Change is **100% logic** (API, state, event handlers, types, utils)
|
||||
2. **Zero** visual keywords in your diff
|
||||
3. No styling, layout, or appearance changes whatsoever
|
||||
|
||||
| Pure Logic Examples | Visual Examples (DELEGATE) |
|
||||
|---------------------|---------------------------|
|
||||
| Add onClick API call | Change button color |
|
||||
| Fix pagination logic | Add loading spinner animation |
|
||||
| Add form validation | Make modal responsive |
|
||||
| Update state management | Adjust spacing/margins |
|
||||
|
||||
#### Mixed Changes → SPLIT
|
||||
|
||||
If change has BOTH logic AND visual:
|
||||
1. Handle logic yourself
|
||||
2. DELEGATE visual part to \`frontend-ui-ux-engineer\`
|
||||
3. **Never** combine them into one edit`
|
||||
}
|
||||
|
||||
export function buildOracleSection(agents: AvailableAgent[]): string {
|
||||
@@ -271,7 +298,7 @@ export function buildHardBlocksSection(agents: AvailableAgent[]): string {
|
||||
|
||||
if (frontendAgent) {
|
||||
blocks.unshift(
|
||||
"| Frontend VISUAL changes (styling, layout, animation) | Always delegate to `frontend-ui-ux-engineer` |"
|
||||
"| Frontend VISUAL changes (styling, className, layout, animation, any visual keyword) | **HARD BLOCK** - Always delegate to `frontend-ui-ux-engineer`. Zero tolerance. |"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -297,7 +324,7 @@ export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|
||||
patterns.splice(
|
||||
4,
|
||||
0,
|
||||
"| **Frontend** | Direct edit to visual/styling code (logic changes OK) |"
|
||||
"| **Frontend** | ANY direct edit to visual/styling code. Keyword detected = DELEGATE. Pure logic only = OK |"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +192,7 @@ export function createBuiltinAgents(
|
||||
|
||||
if (!disabledAgents.includes("orchestrator-sisyphus")) {
|
||||
const orchestratorOverride = agentOverrides["orchestrator-sisyphus"]
|
||||
const orchestratorModel = orchestratorOverride?.model
|
||||
const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel
|
||||
let orchestratorConfig = createOrchestratorSisyphusAgent({
|
||||
model: orchestratorModel,
|
||||
availableAgents,
|
||||
|
||||
@@ -6,17 +6,16 @@ CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runt
|
||||
## STRUCTURE
|
||||
```
|
||||
cli/
|
||||
├── index.ts # Commander.js entry, subcommand routing (184 lines)
|
||||
├── install.ts # Interactive TUI installer (436 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (725 lines)
|
||||
├── index.ts # Commander.js entry, subcommand routing (146 lines)
|
||||
├── install.ts # Interactive TUI installer (462 lines)
|
||||
├── config-manager.ts # JSONC parsing, env detection (730 lines)
|
||||
├── types.ts # CLI-specific types
|
||||
├── commands/ # CLI subcommands (auth.ts)
|
||||
├── doctor/ # Health check system
|
||||
│ ├── index.ts # Doctor command entry
|
||||
│ ├── runner.ts # Health check orchestration
|
||||
│ ├── constants.ts # Check categories
|
||||
│ ├── types.ts # Check result interfaces
|
||||
│ └── checks/ # 10+ check modules (17+ individual checks)
|
||||
│ └── checks/ # 10 check modules (14 individual checks)
|
||||
├── get-local-version/ # Version detection
|
||||
└── run/ # OpenCode session launcher
|
||||
├── completion.ts # Completion logic
|
||||
@@ -28,16 +27,17 @@ cli/
|
||||
|---------|---------|
|
||||
| `install` | Interactive setup wizard with subscription detection |
|
||||
| `doctor` | Environment health checks (LSP, Auth, Config, Deps) |
|
||||
| `run` | Launch OpenCode session with event handling |
|
||||
| `auth` | Manage authentication providers |
|
||||
| `run` | Launch OpenCode session with todo/background completion enforcement |
|
||||
| `get-local-version` | Detect and return local plugin version & update status |
|
||||
|
||||
## DOCTOR CHECKS
|
||||
17+ checks in `doctor/checks/`:
|
||||
- `version.ts`: OpenCode >= 1.0.150
|
||||
14 checks in `doctor/checks/`:
|
||||
- `version.ts`: OpenCode >= 1.0.150 & plugin update status
|
||||
- `config.ts`: Plugin registration & JSONC validity
|
||||
- `dependencies.ts`: bun, node, git, gh-cli
|
||||
- `dependencies.ts`: AST-Grep (CLI/NAPI), Comment Checker
|
||||
- `auth.ts`: Anthropic, OpenAI, Google (Antigravity)
|
||||
- `lsp.ts`, `mcp.ts`: Tool connectivity checks
|
||||
- `gh.ts`: GitHub CLI availability
|
||||
|
||||
## CONFIG-MANAGER
|
||||
- **JSONC**: Supports comments and trailing commas via `parseJsonc`
|
||||
|
||||
@@ -1,6 +1,173 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect, test, mock, beforeEach, afterEach } from "bun:test"
|
||||
|
||||
import { ANTIGRAVITY_PROVIDER_CONFIG } from "./config-manager"
|
||||
import { ANTIGRAVITY_PROVIDER_CONFIG, getPluginNameWithVersion, fetchNpmDistTags, generateOmoConfig } from "./config-manager"
|
||||
import type { InstallConfig } from "./types"
|
||||
|
||||
describe("getPluginNameWithVersion", () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test("returns @latest when current version matches latest tag", async () => {
|
||||
// #given npm dist-tags with latest=2.14.0
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 2.14.0
|
||||
const result = await getPluginNameWithVersion("2.14.0")
|
||||
|
||||
// #then should use @latest tag
|
||||
expect(result).toBe("oh-my-opencode@latest")
|
||||
})
|
||||
|
||||
test("returns @beta when current version matches beta tag", async () => {
|
||||
// #given npm dist-tags with beta=3.0.0-beta.3
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.0.0-beta.3
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.3")
|
||||
|
||||
// #then should use @beta tag
|
||||
expect(result).toBe("oh-my-opencode@beta")
|
||||
})
|
||||
|
||||
test("returns @next when current version matches next tag", async () => {
|
||||
// #given npm dist-tags with next=3.1.0-next.1
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3", next: "3.1.0-next.1" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.1.0-next.1
|
||||
const result = await getPluginNameWithVersion("3.1.0-next.1")
|
||||
|
||||
// #then should use @next tag
|
||||
expect(result).toBe("oh-my-opencode@next")
|
||||
})
|
||||
|
||||
test("returns pinned version when no tag matches", async () => {
|
||||
// #given npm dist-tags with beta=3.0.0-beta.3
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is old beta 3.0.0-beta.2
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.2")
|
||||
|
||||
// #then should pin to specific version
|
||||
expect(result).toBe("oh-my-opencode@3.0.0-beta.2")
|
||||
})
|
||||
|
||||
test("returns pinned version when fetch fails", async () => {
|
||||
// #given network failure
|
||||
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 3.0.0-beta.3
|
||||
const result = await getPluginNameWithVersion("3.0.0-beta.3")
|
||||
|
||||
// #then should fall back to pinned version
|
||||
expect(result).toBe("oh-my-opencode@3.0.0-beta.3")
|
||||
})
|
||||
|
||||
test("returns pinned version when npm returns non-ok response", async () => {
|
||||
// #given npm returns 404
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version is 2.14.0
|
||||
const result = await getPluginNameWithVersion("2.14.0")
|
||||
|
||||
// #then should fall back to pinned version
|
||||
expect(result).toBe("oh-my-opencode@2.14.0")
|
||||
})
|
||||
|
||||
test("prioritizes latest over other tags when version matches multiple", async () => {
|
||||
// #given version matches both latest and beta (during release promotion)
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ beta: "3.0.0", latest: "3.0.0", next: "3.1.0-alpha.1" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when current version matches both
|
||||
const result = await getPluginNameWithVersion("3.0.0")
|
||||
|
||||
// #then should prioritize @latest
|
||||
expect(result).toBe("oh-my-opencode@latest")
|
||||
})
|
||||
})
|
||||
|
||||
describe("fetchNpmDistTags", () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
test("returns dist-tags on success", async () => {
|
||||
// #given npm returns dist-tags
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ latest: "2.14.0", beta: "3.0.0-beta.3" }),
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when fetching dist-tags
|
||||
const result = await fetchNpmDistTags("oh-my-opencode")
|
||||
|
||||
// #then should return the tags
|
||||
expect(result).toEqual({ latest: "2.14.0", beta: "3.0.0-beta.3" })
|
||||
})
|
||||
|
||||
test("returns null on network failure", async () => {
|
||||
// #given network failure
|
||||
globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch
|
||||
|
||||
// #when fetching dist-tags
|
||||
const result = await fetchNpmDistTags("oh-my-opencode")
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("returns null on non-ok response", async () => {
|
||||
// #given npm returns 404
|
||||
globalThis.fetch = mock(() =>
|
||||
Promise.resolve({
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch
|
||||
|
||||
// #when fetching dist-tags
|
||||
const result = await fetchNpmDistTags("oh-my-opencode")
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
test("Gemini models include full spec (limit + modalities)", () => {
|
||||
@@ -32,3 +199,133 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("generateOmoConfig - GitHub Copilot fallback", () => {
|
||||
test("frontend-ui-ux-engineer uses Copilot when no native providers", () => {
|
||||
// #given user has only Copilot (no Claude, ChatGPT, Gemini)
|
||||
const config: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then frontend-ui-ux-engineer should use Copilot Gemini
|
||||
const agents = result.agents as Record<string, { model?: string }>
|
||||
expect(agents["frontend-ui-ux-engineer"]?.model).toBe("github-copilot/gemini-3-pro-preview")
|
||||
})
|
||||
|
||||
test("document-writer uses Copilot when no native providers", () => {
|
||||
// #given user has only Copilot
|
||||
const config: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then document-writer should use Copilot Gemini Flash
|
||||
const agents = result.agents as Record<string, { model?: string }>
|
||||
expect(agents["document-writer"]?.model).toBe("github-copilot/gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("multimodal-looker uses Copilot when no native providers", () => {
|
||||
// #given user has only Copilot
|
||||
const config: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then multimodal-looker should use Copilot Gemini Flash
|
||||
const agents = result.agents as Record<string, { model?: string }>
|
||||
expect(agents["multimodal-looker"]?.model).toBe("github-copilot/gemini-3-flash-preview")
|
||||
})
|
||||
|
||||
test("explore uses Copilot grok-code when no native providers", () => {
|
||||
// #given user has only Copilot
|
||||
const config: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then explore should use Copilot Grok
|
||||
const agents = result.agents as Record<string, { model?: string }>
|
||||
expect(agents["explore"]?.model).toBe("github-copilot/grok-code-fast-1")
|
||||
})
|
||||
|
||||
test("native Gemini takes priority over Copilot for frontend-ui-ux-engineer", () => {
|
||||
// #given user has both Gemini and Copilot
|
||||
const config: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: true,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then native Gemini should be used (NOT Copilot)
|
||||
const agents = result.agents as Record<string, { model?: string }>
|
||||
expect(agents["frontend-ui-ux-engineer"]?.model).toBe("google/antigravity-gemini-3-pro-high")
|
||||
})
|
||||
|
||||
test("native Claude takes priority over Copilot for frontend-ui-ux-engineer", () => {
|
||||
// #given user has Claude and Copilot but no Gemini
|
||||
const config: InstallConfig = {
|
||||
hasClaude: true,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then native Claude should be used (NOT Copilot)
|
||||
const agents = result.agents as Record<string, { model?: string }>
|
||||
expect(agents["frontend-ui-ux-engineer"]?.model).toBe("anthropic/claude-opus-4-5")
|
||||
})
|
||||
|
||||
test("categories use Copilot models when no native Gemini", () => {
|
||||
// #given user has Copilot but no Gemini
|
||||
const config: InstallConfig = {
|
||||
hasClaude: false,
|
||||
isMax20: false,
|
||||
hasChatGPT: false,
|
||||
hasGemini: false,
|
||||
hasCopilot: true,
|
||||
}
|
||||
|
||||
// #when generating config
|
||||
const result = generateOmoConfig(config)
|
||||
|
||||
// #then categories should use Copilot models
|
||||
const categories = result.categories as Record<string, { model?: string }>
|
||||
expect(categories?.["visual-engineering"]?.model).toBe("github-copilot/gemini-3-pro-preview")
|
||||
expect(categories?.["artistry"]?.model).toBe("github-copilot/gemini-3-pro-preview")
|
||||
expect(categories?.["writing"]?.model).toBe("github-copilot/gemini-3-flash-preview")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
parseJsonc,
|
||||
getOpenCodeConfigPaths,
|
||||
@@ -109,6 +108,47 @@ export async function fetchLatestVersion(packageName: string): Promise<string |
|
||||
}
|
||||
}
|
||||
|
||||
interface NpmDistTags {
|
||||
latest?: string
|
||||
beta?: string
|
||||
next?: string
|
||||
[tag: string]: string | undefined
|
||||
}
|
||||
|
||||
const NPM_FETCH_TIMEOUT_MS = 5000
|
||||
|
||||
export async function fetchNpmDistTags(packageName: string): Promise<NpmDistTags | null> {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/-/package/${packageName}/dist-tags`, {
|
||||
signal: AbortSignal.timeout(NPM_FETCH_TIMEOUT_MS),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = await res.json() as NpmDistTags
|
||||
return data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const PACKAGE_NAME = "oh-my-opencode"
|
||||
|
||||
const PRIORITIZED_TAGS = ["latest", "beta", "next"] as const
|
||||
|
||||
export async function getPluginNameWithVersion(currentVersion: string): Promise<string> {
|
||||
const distTags = await fetchNpmDistTags(PACKAGE_NAME)
|
||||
|
||||
if (distTags) {
|
||||
const allTags = new Set([...PRIORITIZED_TAGS, ...Object.keys(distTags)])
|
||||
for (const tag of allTags) {
|
||||
if (distTags[tag] === currentVersion) {
|
||||
return `${PACKAGE_NAME}@${tag}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `${PACKAGE_NAME}@${currentVersion}`
|
||||
}
|
||||
|
||||
type ConfigFormat = "json" | "jsonc" | "none"
|
||||
|
||||
interface OpenCodeConfig {
|
||||
@@ -179,7 +219,7 @@ function ensureConfigDir(): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
export async function addPluginToOpenCodeConfig(currentVersion: string): Promise<ConfigMergeResult> {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
@@ -187,11 +227,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
const pluginName = "oh-my-opencode"
|
||||
const pluginEntry = await getPluginNameWithVersion(currentVersion)
|
||||
|
||||
try {
|
||||
if (format === "none") {
|
||||
const config: OpenCodeConfig = { plugin: [pluginName] }
|
||||
const config: OpenCodeConfig = { plugin: [pluginEntry] }
|
||||
writeFileSync(path, JSON.stringify(config, null, 2) + "\n")
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
@@ -203,11 +243,18 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
|
||||
const config = parseResult.config
|
||||
const plugins = config.plugin ?? []
|
||||
if (plugins.some((p) => p.startsWith(pluginName))) {
|
||||
return { success: true, configPath: path }
|
||||
const existingIndex = plugins.findIndex((p) => p === PACKAGE_NAME || p.startsWith(`${PACKAGE_NAME}@`))
|
||||
|
||||
if (existingIndex !== -1) {
|
||||
if (plugins[existingIndex] === pluginEntry) {
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
plugins[existingIndex] = pluginEntry
|
||||
} else {
|
||||
plugins.push(pluginEntry)
|
||||
}
|
||||
|
||||
config.plugin = [...plugins, pluginName]
|
||||
config.plugin = plugins
|
||||
|
||||
if (format === "jsonc") {
|
||||
const content = readFileSync(path, "utf-8")
|
||||
@@ -215,14 +262,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
const match = content.match(pluginArrayRegex)
|
||||
|
||||
if (match) {
|
||||
const arrayContent = match[1].trim()
|
||||
const newArrayContent = arrayContent
|
||||
? `${arrayContent},\n "${pluginName}"`
|
||||
: `"${pluginName}"`
|
||||
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${newArrayContent}\n ]`)
|
||||
const formattedPlugins = plugins.map((p) => `"${p}"`).join(",\n ")
|
||||
const newContent = content.replace(pluginArrayRegex, `"plugin": [\n ${formattedPlugins}\n ]`)
|
||||
writeFileSync(path, newContent)
|
||||
} else {
|
||||
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginName}"],`)
|
||||
const newContent = content.replace(/^(\s*\{)/, `$1\n "plugin": ["${pluginEntry}"],`)
|
||||
writeFileSync(path, newContent)
|
||||
}
|
||||
} else {
|
||||
@@ -270,7 +314,9 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
|
||||
const agents: Record<string, Record<string, unknown>> = {}
|
||||
|
||||
if (!installConfig.hasClaude) {
|
||||
agents["Sisyphus"] = { model: "opencode/glm-4.7-free" }
|
||||
agents["Sisyphus"] = {
|
||||
model: installConfig.hasCopilot ? "github-copilot/claude-opus-4.5" : "opencode/glm-4.7-free",
|
||||
}
|
||||
}
|
||||
|
||||
agents["librarian"] = { model: "opencode/glm-4.7-free" }
|
||||
@@ -281,38 +327,56 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
|
||||
agents["explore"] = { model: "google/antigravity-gemini-3-flash" }
|
||||
} else if (installConfig.hasClaude && installConfig.isMax20) {
|
||||
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else if (installConfig.hasCopilot) {
|
||||
agents["explore"] = { model: "github-copilot/grok-code-fast-1" }
|
||||
} else {
|
||||
agents["explore"] = { model: "opencode/glm-4.7-free" }
|
||||
}
|
||||
|
||||
if (!installConfig.hasChatGPT) {
|
||||
agents["oracle"] = {
|
||||
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free",
|
||||
}
|
||||
const oracleFallback = installConfig.hasCopilot
|
||||
? "github-copilot/gpt-5.2"
|
||||
: installConfig.hasClaude
|
||||
? "anthropic/claude-opus-4-5"
|
||||
: "opencode/glm-4.7-free"
|
||||
agents["oracle"] = { model: oracleFallback }
|
||||
}
|
||||
|
||||
if (installConfig.hasGemini) {
|
||||
agents["frontend-ui-ux-engineer"] = { model: "google/antigravity-gemini-3-pro-high" }
|
||||
agents["document-writer"] = { model: "google/antigravity-gemini-3-flash" }
|
||||
agents["multimodal-looker"] = { model: "google/antigravity-gemini-3-flash" }
|
||||
} else if (installConfig.hasClaude) {
|
||||
agents["frontend-ui-ux-engineer"] = { model: "anthropic/claude-opus-4-5" }
|
||||
agents["document-writer"] = { model: "anthropic/claude-opus-4-5" }
|
||||
agents["multimodal-looker"] = { model: "anthropic/claude-opus-4-5" }
|
||||
} else if (installConfig.hasCopilot) {
|
||||
agents["frontend-ui-ux-engineer"] = { model: "github-copilot/gemini-3-pro-preview" }
|
||||
agents["document-writer"] = { model: "github-copilot/gemini-3-flash-preview" }
|
||||
agents["multimodal-looker"] = { model: "github-copilot/gemini-3-flash-preview" }
|
||||
} else {
|
||||
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free"
|
||||
agents["frontend-ui-ux-engineer"] = { model: fallbackModel }
|
||||
agents["document-writer"] = { model: fallbackModel }
|
||||
agents["multimodal-looker"] = { model: fallbackModel }
|
||||
agents["frontend-ui-ux-engineer"] = { model: "opencode/glm-4.7-free" }
|
||||
agents["document-writer"] = { model: "opencode/glm-4.7-free" }
|
||||
agents["multimodal-looker"] = { model: "opencode/glm-4.7-free" }
|
||||
}
|
||||
|
||||
if (Object.keys(agents).length > 0) {
|
||||
config.agents = agents
|
||||
}
|
||||
|
||||
// Categories: override model for Antigravity auth (gemini-3-pro-preview → gemini-3-pro-high)
|
||||
// Categories: override model for Antigravity auth or GitHub Copilot fallback
|
||||
if (installConfig.hasGemini) {
|
||||
config.categories = {
|
||||
"visual-engineering": { model: "google/gemini-3-pro-high" },
|
||||
artistry: { model: "google/gemini-3-pro-high" },
|
||||
writing: { model: "google/gemini-3-flash-high" },
|
||||
}
|
||||
} else if (installConfig.hasCopilot) {
|
||||
config.categories = {
|
||||
"visual-engineering": { model: "github-copilot/gemini-3-pro-preview" },
|
||||
artistry: { model: "github-copilot/gemini-3-pro-preview" },
|
||||
writing: { model: "github-copilot/gemini-3-flash-preview" },
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
@@ -431,11 +495,7 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
}
|
||||
}
|
||||
|
||||
if (config.hasChatGPT) {
|
||||
if (!plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))) {
|
||||
plugins.push("opencode-openai-codex-auth")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const newConfig = { ...(existingConfig ?? {}), plugin: plugins }
|
||||
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
@@ -545,54 +605,7 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
||||
},
|
||||
}
|
||||
|
||||
const CODEX_PROVIDER_CONFIG = {
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
options: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.2": {
|
||||
name: "GPT 5.2 (OAuth)",
|
||||
limit: { context: 272000, output: 128000 },
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
none: { reasoningEffort: "none", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
},
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
name: "GPT 5.2 Codex (OAuth)",
|
||||
limit: { context: 272000, output: 128000 },
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
},
|
||||
},
|
||||
"gpt-5.1-codex-max": {
|
||||
name: "GPT 5.1 Codex Max (OAuth)",
|
||||
limit: { context: 272000, output: 128000 },
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: { reasoningEffort: "low", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
medium: { reasoningEffort: "medium", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
try {
|
||||
@@ -622,10 +635,6 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
providers.google = ANTIGRAVITY_PROVIDER_CONFIG.google
|
||||
}
|
||||
|
||||
if (config.hasChatGPT) {
|
||||
providers.openai = CODEX_PROVIDER_CONFIG.openai
|
||||
}
|
||||
|
||||
if (Object.keys(providers).length > 0) {
|
||||
newConfig.provider = providers
|
||||
}
|
||||
@@ -648,6 +657,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
isMax20: true,
|
||||
hasChatGPT: true,
|
||||
hasGemini: false,
|
||||
hasCopilot: false,
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -669,7 +679,6 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
}
|
||||
|
||||
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
|
||||
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
|
||||
|
||||
const omoConfigPath = getOmoConfig()
|
||||
if (!existsSync(omoConfigPath)) {
|
||||
@@ -708,6 +717,11 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
result.hasChatGPT = false
|
||||
}
|
||||
|
||||
const hasAnyCopilotModel = Object.values(agents).some(
|
||||
(agent) => agent?.model?.startsWith("github-copilot/")
|
||||
)
|
||||
result.hasCopilot = hasAnyCopilotModel
|
||||
|
||||
} catch {
|
||||
/* intentionally empty - malformed omo config returns defaults from opencode config detection */
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
import type { DoctorOptions } from "./doctor"
|
||||
import packageJson from "../../package.json" with { type: "json" }
|
||||
|
||||
const packageJson = await import("../../package.json")
|
||||
const VERSION = packageJson.version
|
||||
|
||||
const program = new Command()
|
||||
@@ -26,12 +26,13 @@ program
|
||||
.option("--claude <value>", "Claude subscription: no, yes, max20")
|
||||
.option("--chatgpt <value>", "ChatGPT subscription: no, yes")
|
||||
.option("--gemini <value>", "Gemini integration: no, yes")
|
||||
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
|
||||
.option("--skip-auth", "Skip authentication setup hints")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ bunx oh-my-opencode install
|
||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes
|
||||
$ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no
|
||||
$ bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes --copilot=no
|
||||
$ bunx oh-my-opencode install --no-tui --claude=no --chatgpt=no --gemini=no --copilot=yes
|
||||
|
||||
Model Providers:
|
||||
Claude Required for Sisyphus (main orchestrator) and Librarian agents
|
||||
@@ -44,6 +45,7 @@ Model Providers:
|
||||
claude: options.claude,
|
||||
chatgpt: options.chatgpt,
|
||||
gemini: options.gemini,
|
||||
copilot: options.copilot,
|
||||
skipAuth: options.skipAuth ?? false,
|
||||
}
|
||||
const exitCode = await install(args)
|
||||
|
||||
@@ -10,6 +10,9 @@ import {
|
||||
addProviderConfig,
|
||||
detectCurrentConfig,
|
||||
} from "./config-manager"
|
||||
import packageJson from "../../package.json" with { type: "json" }
|
||||
|
||||
const VERSION = packageJson.version
|
||||
|
||||
const SYMBOLS = {
|
||||
check: color.green("✓"),
|
||||
@@ -38,6 +41,7 @@ function formatConfigSummary(config: InstallConfig): string {
|
||||
lines.push(formatProvider("Claude", config.hasClaude, claudeDetail))
|
||||
lines.push(formatProvider("ChatGPT", config.hasChatGPT))
|
||||
lines.push(formatProvider("Gemini", config.hasGemini))
|
||||
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback provider"))
|
||||
|
||||
lines.push("")
|
||||
lines.push(color.dim("─".repeat(40)))
|
||||
@@ -46,8 +50,8 @@ function formatConfigSummary(config: InstallConfig): string {
|
||||
lines.push(color.bold(color.white("Agent Configuration")))
|
||||
lines.push("")
|
||||
|
||||
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free"
|
||||
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
|
||||
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : (config.hasCopilot ? "github-copilot/claude-opus-4.5" : "glm-4.7-free")
|
||||
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasCopilot ? "github-copilot/gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free"))
|
||||
const librarianModel = "glm-4.7-free"
|
||||
const frontendModel = config.hasGemini ? "antigravity-gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
|
||||
|
||||
@@ -130,6 +134,12 @@ function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string
|
||||
errors.push(`Invalid --gemini value: ${args.gemini} (expected: no, yes)`)
|
||||
}
|
||||
|
||||
if (args.copilot === undefined) {
|
||||
errors.push("--copilot is required (values: no, yes)")
|
||||
} else if (!["no", "yes"].includes(args.copilot)) {
|
||||
errors.push(`Invalid --copilot value: ${args.copilot} (expected: no, yes)`)
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
}
|
||||
|
||||
@@ -139,10 +149,11 @@ function argsToConfig(args: InstallArgs): InstallConfig {
|
||||
isMax20: args.claude === "max20",
|
||||
hasChatGPT: args.chatgpt === "yes",
|
||||
hasGemini: args.gemini === "yes",
|
||||
hasCopilot: args.copilot === "yes",
|
||||
}
|
||||
}
|
||||
|
||||
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; chatgpt: BooleanArg; gemini: BooleanArg } {
|
||||
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; chatgpt: BooleanArg; gemini: BooleanArg; copilot: BooleanArg } {
|
||||
let claude: ClaudeSubscription = "no"
|
||||
if (detected.hasClaude) {
|
||||
claude = detected.isMax20 ? "max20" : "yes"
|
||||
@@ -152,6 +163,7 @@ function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubs
|
||||
claude,
|
||||
chatgpt: detected.hasChatGPT ? "yes" : "no",
|
||||
gemini: detected.hasGemini ? "yes" : "no",
|
||||
copilot: detected.hasCopilot ? "yes" : "no",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,11 +213,26 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
return null
|
||||
}
|
||||
|
||||
const copilot = await p.select({
|
||||
message: "Do you have a GitHub Copilot subscription?",
|
||||
options: [
|
||||
{ value: "no" as const, label: "No", hint: "Only native providers will be used" },
|
||||
{ value: "yes" as const, label: "Yes", hint: "Fallback option when native providers unavailable" },
|
||||
],
|
||||
initialValue: initial.copilot,
|
||||
})
|
||||
|
||||
if (p.isCancel(copilot)) {
|
||||
p.cancel("Installation cancelled.")
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
hasClaude: claude !== "no",
|
||||
isMax20: claude === "max20",
|
||||
hasChatGPT: chatgpt === "yes",
|
||||
hasGemini: gemini === "yes",
|
||||
hasCopilot: copilot === "yes",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,7 +245,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
console.log(` ${SYMBOLS.bullet} ${err}`)
|
||||
}
|
||||
console.log()
|
||||
printInfo("Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --chatgpt=<no|yes> --gemini=<no|yes>")
|
||||
printInfo("Usage: bunx oh-my-opencode install --no-tui --claude=<no|yes|max20> --chatgpt=<no|yes> --gemini=<no|yes> --copilot=<no|yes>")
|
||||
console.log()
|
||||
return 1
|
||||
}
|
||||
@@ -250,14 +277,14 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
const config = argsToConfig(args)
|
||||
|
||||
printStep(step++, totalSteps, "Adding oh-my-opencode plugin...")
|
||||
const pluginResult = addPluginToOpenCodeConfig()
|
||||
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
|
||||
if (!pluginResult.success) {
|
||||
printError(`Failed: ${pluginResult.error}`)
|
||||
return 1
|
||||
}
|
||||
printSuccess(`Plugin ${isUpdate ? "verified" : "added"} ${SYMBOLS.arrow} ${color.dim(pluginResult.configPath)}`)
|
||||
|
||||
if (config.hasGemini || config.hasChatGPT) {
|
||||
if (config.hasGemini) {
|
||||
printStep(step++, totalSteps, "Adding auth plugins...")
|
||||
const authResult = await addAuthPlugins(config)
|
||||
if (!authResult.success) {
|
||||
@@ -287,25 +314,10 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
|
||||
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
||||
|
||||
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
|
||||
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini && !config.hasCopilot) {
|
||||
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
|
||||
}
|
||||
|
||||
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
|
||||
console.log(color.bold("Next Steps - Authenticate your providers:"))
|
||||
console.log()
|
||||
if (config.hasClaude) {
|
||||
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`)
|
||||
}
|
||||
if (config.hasChatGPT) {
|
||||
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`)
|
||||
}
|
||||
if (config.hasGemini) {
|
||||
console.log(` ${SYMBOLS.arrow} ${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
|
||||
console.log(` Run ${color.cyan("opencode")} to start!`)
|
||||
console.log()
|
||||
@@ -323,6 +335,17 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
console.log(color.dim("oMoMoMoMo... Enjoy!"))
|
||||
console.log()
|
||||
|
||||
if ((config.hasClaude || config.hasChatGPT || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
|
||||
printBox(
|
||||
`Run ${color.cyan("opencode auth login")} and select your provider:\n` +
|
||||
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
|
||||
(config.hasChatGPT ? ` ${SYMBOLS.bullet} OpenAI ${color.gray("→ ChatGPT Plus/Pro")}\n` : "") +
|
||||
(config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") +
|
||||
(config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""),
|
||||
"🔐 Authenticate Your Providers"
|
||||
)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -360,7 +383,7 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
if (!config) return 1
|
||||
|
||||
s.start("Adding oh-my-opencode to OpenCode config")
|
||||
const pluginResult = addPluginToOpenCodeConfig()
|
||||
const pluginResult = await addPluginToOpenCodeConfig(VERSION)
|
||||
if (!pluginResult.success) {
|
||||
s.stop(`Failed to add plugin: ${pluginResult.error}`)
|
||||
p.outro(color.red("Installation failed."))
|
||||
@@ -368,7 +391,7 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
}
|
||||
s.stop(`Plugin added to ${color.cyan(pluginResult.configPath)}`)
|
||||
|
||||
if (config.hasGemini || config.hasChatGPT) {
|
||||
if (config.hasGemini) {
|
||||
s.start("Adding auth plugins (fetching latest versions)")
|
||||
const authResult = await addAuthPlugins(config)
|
||||
if (!authResult.success) {
|
||||
@@ -397,26 +420,12 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
}
|
||||
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
|
||||
|
||||
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
|
||||
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini && !config.hasCopilot) {
|
||||
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
|
||||
}
|
||||
|
||||
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
||||
|
||||
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
|
||||
const steps: string[] = []
|
||||
if (config.hasClaude) {
|
||||
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Anthropic → Claude Pro/Max)")}`)
|
||||
}
|
||||
if (config.hasChatGPT) {
|
||||
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select OpenAI → ChatGPT Plus/Pro)")}`)
|
||||
}
|
||||
if (config.hasGemini) {
|
||||
steps.push(`${color.dim("opencode auth login")} ${color.gray("(select Google → OAuth with Antigravity)")}`)
|
||||
}
|
||||
p.note(steps.join("\n"), "Next Steps - Authenticate your providers")
|
||||
}
|
||||
|
||||
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
|
||||
p.log.message(`Run ${color.cyan("opencode")} to start!`)
|
||||
|
||||
@@ -432,5 +441,22 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
|
||||
p.outro(color.green("oMoMoMoMo... Enjoy!"))
|
||||
|
||||
if ((config.hasClaude || config.hasChatGPT || config.hasGemini || config.hasCopilot) && !args.skipAuth) {
|
||||
const providers: string[] = []
|
||||
if (config.hasClaude) providers.push(`Anthropic ${color.gray("→ Claude Pro/Max")}`)
|
||||
if (config.hasChatGPT) providers.push(`OpenAI ${color.gray("→ ChatGPT Plus/Pro")}`)
|
||||
if (config.hasGemini) providers.push(`Google ${color.gray("→ OAuth with Antigravity")}`)
|
||||
if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`)
|
||||
|
||||
console.log()
|
||||
console.log(color.bold("🔐 Authenticate Your Providers"))
|
||||
console.log()
|
||||
console.log(` Run ${color.cyan("opencode auth login")} and select:`)
|
||||
for (const provider of providers) {
|
||||
console.log(` ${SYMBOLS.bullet} ${provider}`)
|
||||
}
|
||||
console.log()
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface InstallArgs {
|
||||
claude?: ClaudeSubscription
|
||||
chatgpt?: BooleanArg
|
||||
gemini?: BooleanArg
|
||||
copilot?: BooleanArg
|
||||
skipAuth?: boolean
|
||||
}
|
||||
|
||||
@@ -14,6 +15,7 @@ export interface InstallConfig {
|
||||
isMax20: boolean
|
||||
hasChatGPT: boolean
|
||||
hasGemini: boolean
|
||||
hasCopilot: boolean
|
||||
}
|
||||
|
||||
export interface ConfigMergeResult {
|
||||
@@ -28,4 +30,5 @@ export interface DetectedConfig {
|
||||
isMax20: boolean
|
||||
hasChatGPT: boolean
|
||||
hasGemini: boolean
|
||||
hasCopilot: boolean
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export const HookNameSchema = z.enum([
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
"sisyphus-task-retry",
|
||||
"prometheus-md-only",
|
||||
"start-work",
|
||||
"sisyphus-orchestrator",
|
||||
@@ -198,7 +199,7 @@ export const DynamicContextPruningConfigSchema = z.object({
|
||||
/** Tools that should never be pruned */
|
||||
protected_tools: z.array(z.string()).default([
|
||||
"task", "todowrite", "todoread",
|
||||
"lsp_rename", "lsp_code_action_resolve",
|
||||
"lsp_rename",
|
||||
"session_read", "session_write", "session_search",
|
||||
]),
|
||||
/** Pruning strategies configuration */
|
||||
|
||||
@@ -6,13 +6,13 @@ Claude Code compatibility layer + core feature modules. Commands, skills, agents
|
||||
## STRUCTURE
|
||||
```
|
||||
features/
|
||||
├── background-agent/ # Task lifecycle, notifications (825 lines manager.ts)
|
||||
├── background-agent/ # Task lifecycle, notifications (928 lines manager.ts)
|
||||
├── boulder-state/ # Boulder state persistence
|
||||
├── builtin-commands/ # Built-in slash commands
|
||||
│ └── templates/ # start-work, refactor, init-deep, ralph-loop
|
||||
├── builtin-skills/ # Built-in skills (1230 lines skills.ts)
|
||||
│ ├── git-master/ # Atomic commits, rebase, history search
|
||||
│ ├── playwright/ # Browser automation skill
|
||||
│ ├── playwright # Browser automation skill
|
||||
│ └── frontend-ui-ux/ # Designer-turned-developer skill
|
||||
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # ~/.claude/commands/*.md
|
||||
@@ -24,8 +24,7 @@ features/
|
||||
├── opencode-skill-loader/ # Skills from OpenCode + Claude paths
|
||||
├── skill-mcp-manager/ # MCP servers in skill YAML
|
||||
├── task-toast-manager/ # Task toast notifications
|
||||
├── hook-message-injector/ # Inject messages into conversation
|
||||
└── context-injector/ # Context collection and injection
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
## LOADER PRIORITY
|
||||
|
||||
@@ -675,93 +675,140 @@ describe("LaunchInput.skillContent", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - agent context preservation", () => {
|
||||
test("should not pass agent field when parentAgent is undefined", async () => {
|
||||
// #given
|
||||
interface CurrentMessage {
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
}
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - dynamic message lookup", () => {
|
||||
test("should use currentMessage model/agent when available", async () => {
|
||||
// #given - currentMessage has model and agent
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-agent",
|
||||
id: "task-1",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task without agent context",
|
||||
description: "task with dynamic lookup",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: undefined,
|
||||
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
|
||||
parentAgent: "OldAgent",
|
||||
parentModel: { providerID: "old", modelID: "old-model" },
|
||||
}
|
||||
const currentMessage: CurrentMessage = {
|
||||
agent: "Sisyphus",
|
||||
model: { providerID: "anthropic", modelID: "claude-opus-4-5" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task)
|
||||
const promptBody = buildNotificationPromptBody(task, currentMessage)
|
||||
|
||||
// #then
|
||||
expect("agent" in promptBody).toBe(false)
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus" })
|
||||
})
|
||||
|
||||
test("should include agent field when parentAgent is defined", async () => {
|
||||
// #given
|
||||
const task: BackgroundTask = {
|
||||
id: "task-with-agent",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task with agent context",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: "Sisyphus",
|
||||
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task)
|
||||
|
||||
// #then
|
||||
// #then - uses currentMessage values, not task.parentModel/parentAgent
|
||||
expect(promptBody.agent).toBe("Sisyphus")
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-5" })
|
||||
})
|
||||
|
||||
test("should not pass model field when parentModel is undefined", async () => {
|
||||
test("should fallback to parentAgent when currentMessage.agent is undefined", async () => {
|
||||
// #given
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-model",
|
||||
id: "task-2",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task without model context",
|
||||
description: "task fallback agent",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: "Sisyphus",
|
||||
parentAgent: "FallbackAgent",
|
||||
parentModel: undefined,
|
||||
}
|
||||
const currentMessage: CurrentMessage = { agent: undefined, model: undefined }
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task)
|
||||
const promptBody = buildNotificationPromptBody(task, currentMessage)
|
||||
|
||||
// #then
|
||||
// #then - falls back to task.parentAgent
|
||||
expect(promptBody.agent).toBe("FallbackAgent")
|
||||
expect("model" in promptBody).toBe(false)
|
||||
})
|
||||
|
||||
test("should not pass model when currentMessage.model is incomplete", async () => {
|
||||
// #given - model missing modelID
|
||||
const task: BackgroundTask = {
|
||||
id: "task-3",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task incomplete model",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: "Sisyphus",
|
||||
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
|
||||
}
|
||||
const currentMessage: CurrentMessage = {
|
||||
agent: "Sisyphus",
|
||||
model: { providerID: "anthropic" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task, currentMessage)
|
||||
|
||||
// #then - model not passed due to incomplete data
|
||||
expect(promptBody.agent).toBe("Sisyphus")
|
||||
expect("model" in promptBody).toBe(false)
|
||||
})
|
||||
|
||||
test("should handle null currentMessage gracefully", async () => {
|
||||
// #given - no message found (messageDir lookup failed)
|
||||
const task: BackgroundTask = {
|
||||
id: "task-4",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task no message",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: "Sisyphus",
|
||||
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task, null)
|
||||
|
||||
// #then - falls back to task.parentAgent, no model
|
||||
expect(promptBody.agent).toBe("Sisyphus")
|
||||
expect("model" in promptBody).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
function buildNotificationPromptBody(task: BackgroundTask): Record<string, unknown> {
|
||||
function buildNotificationPromptBody(
|
||||
task: BackgroundTask,
|
||||
currentMessage: CurrentMessage | null
|
||||
): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }],
|
||||
}
|
||||
|
||||
if (task.parentAgent !== undefined) {
|
||||
body.agent = task.parentAgent
|
||||
}
|
||||
const agent = currentMessage?.agent ?? task.parentAgent
|
||||
const model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
if (task.parentModel?.providerID && task.parentModel?.modelID) {
|
||||
body.model = { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID }
|
||||
if (agent !== undefined) {
|
||||
body.agent = agent
|
||||
}
|
||||
if (model !== undefined) {
|
||||
body.model = model
|
||||
}
|
||||
|
||||
return body
|
||||
|
||||
@@ -11,6 +11,9 @@ import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-injector"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
|
||||
@@ -183,6 +186,7 @@ export class BackgroundManager {
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
@@ -286,6 +290,9 @@ export class BackgroundManager {
|
||||
existingTask.parentMessageID = input.parentMessageID
|
||||
existingTask.parentModel = input.parentModel
|
||||
existingTask.parentAgent = input.parentAgent
|
||||
// Reset startedAt on resume to prevent immediate completion
|
||||
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
|
||||
existingTask.startedAt = new Date()
|
||||
|
||||
existingTask.progress = {
|
||||
toolCalls: existingTask.progress?.toolCalls ?? 0,
|
||||
@@ -337,6 +344,11 @@ export class BackgroundManager {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
// Release concurrency on resume error (matches launch error handler)
|
||||
if (existingTask.concurrencyKey) {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
existingTask.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
@@ -418,6 +430,13 @@ export class BackgroundManager {
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// Release concurrency immediately on completion
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via session.idle event:", task.id)
|
||||
@@ -442,7 +461,10 @@ export class BackgroundManager {
|
||||
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
subagentSessions.delete(sessionID)
|
||||
@@ -534,6 +556,21 @@ export class BackgroundManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove task from pending tracking for its parent session.
|
||||
* Cleans up the parent entry if no pending tasks remain.
|
||||
*/
|
||||
private cleanupPendingByParent(task: BackgroundTask): void {
|
||||
if (!task.parentSessionID) return
|
||||
const pending = this.pendingByParent.get(task.parentSessionID)
|
||||
if (pending) {
|
||||
pending.delete(task.id)
|
||||
if (pending.size === 0) {
|
||||
this.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollingInterval) return
|
||||
|
||||
@@ -638,13 +675,44 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
// Inject notification via session.prompt with noReply
|
||||
let agent: string | undefined = task.parentAgent
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await this.client.session.messages({ path: { id: task.parentSessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
agent = info.agent ?? task.parentAgent
|
||||
model = info.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent ?? task.parentAgent
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
log("[background-agent] notifyParentSession context:", {
|
||||
taskId: task.id,
|
||||
resolvedAgent: agent,
|
||||
resolvedModel: model,
|
||||
})
|
||||
|
||||
try {
|
||||
await this.client.session.prompt({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
noReply: !allComplete, // Silent unless all complete
|
||||
agent: task.parentAgent,
|
||||
noReply: !allComplete,
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
@@ -659,6 +727,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(() => {
|
||||
// Concurrency already released at completion - just cleanup notifications and task
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
@@ -698,7 +767,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
|
||||
task.completedAt = new Date()
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
@@ -751,6 +823,13 @@ try {
|
||||
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// Release concurrency immediately on completion
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via polling:", task.id)
|
||||
@@ -817,6 +896,13 @@ if (lastMessage) {
|
||||
if (!hasIncompleteTodos) {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
// Release concurrency immediately on completion
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined // Prevent double-release
|
||||
}
|
||||
// Clean up pendingByParent to prevent stale entries
|
||||
this.cleanupPendingByParent(task)
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via stability detection:", task.id)
|
||||
@@ -839,3 +925,16 @@ if (lastMessage) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -117,13 +117,13 @@ If \`--create-new\`: Read all existing first (preserve context) → then delete
|
||||
lsp_servers() # Check availability
|
||||
|
||||
# Entry points (parallel)
|
||||
lsp_document_symbols(filePath="src/index.ts")
|
||||
lsp_document_symbols(filePath="main.py")
|
||||
lsp_symbols(filePath="src/index.ts", scope="document")
|
||||
lsp_symbols(filePath="main.py", scope="document")
|
||||
|
||||
# Key symbols (parallel)
|
||||
lsp_workspace_symbols(filePath=".", query="class")
|
||||
lsp_workspace_symbols(filePath=".", query="interface")
|
||||
lsp_workspace_symbols(filePath=".", query="function")
|
||||
lsp_symbols(filePath=".", scope="workspace", query="class")
|
||||
lsp_symbols(filePath=".", scope="workspace", query="interface")
|
||||
lsp_symbols(filePath=".", scope="workspace", query="function")
|
||||
|
||||
# Centrality for top exports
|
||||
lsp_find_references(filePath="...", line=X, character=Y)
|
||||
|
||||
@@ -148,20 +148,15 @@ While background agents are running, use direct tools:
|
||||
### LSP Tools for Precise Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Get symbol information at target location
|
||||
lsp_hover(filePath, line, character) // Type info, docs, signatures
|
||||
|
||||
// Find definition(s)
|
||||
lsp_goto_definition(filePath, line, character) // Where is it defined?
|
||||
|
||||
// Find ALL usages across workspace
|
||||
lsp_find_references(filePath, line, character, includeDeclaration=true)
|
||||
|
||||
// Get file structure
|
||||
lsp_document_symbols(filePath) // Hierarchical outline
|
||||
|
||||
// Search symbols by name
|
||||
lsp_workspace_symbols(filePath, query="[target_symbol]")
|
||||
// Get file structure (scope='document') or search symbols (scope='workspace')
|
||||
lsp_symbols(filePath, scope="document") // Hierarchical outline
|
||||
lsp_symbols(filePath, scope="workspace", query="[target_symbol]") // Search by name
|
||||
|
||||
// Get current diagnostics
|
||||
lsp_diagnostics(filePath) // Errors, warnings before we start
|
||||
@@ -593,7 +588,7 @@ You already know these tools. Use them intelligently:
|
||||
|
||||
## LSP Tools
|
||||
Leverage the full LSP toolset (\`lsp_*\`) for precision analysis. Key patterns:
|
||||
- **Understand before changing**: \`lsp_hover\`, \`lsp_goto_definition\` to grasp context
|
||||
- **Understand before changing**: \`lsp_goto_definition\` to grasp context
|
||||
- **Impact analysis**: \`lsp_find_references\` to map all usages before modification
|
||||
- **Safe refactoring**: \`lsp_prepare_rename\` → \`lsp_rename\` for symbol renames
|
||||
- **Continuous verification**: \`lsp_diagnostics\` after every change
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { injectHookMessage, findNearestMessageWithFields, findFirstMessageWithAgent } from "./injector"
|
||||
export type { StoredMessage } from "./injector"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
|
||||
export { MESSAGE_STORAGE } from "./constants"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveSkillContent, resolveMultipleSkills } from "./skill-content"
|
||||
import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content"
|
||||
|
||||
describe("resolveSkillContent", () => {
|
||||
it("should return template for existing skill", () => {
|
||||
@@ -109,3 +109,87 @@ describe("resolveMultipleSkills", () => {
|
||||
expect(result.resolved.size).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveSkillContentAsync", () => {
|
||||
it("should return template for builtin skill", async () => {
|
||||
// #given: builtin skill 'frontend-ui-ux'
|
||||
// #when: resolving content async
|
||||
const result = await resolveSkillContentAsync("frontend-ui-ux")
|
||||
|
||||
// #then: returns template string
|
||||
expect(result).not.toBeNull()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toContain("Role: Designer-Turned-Developer")
|
||||
})
|
||||
|
||||
it("should return null for non-existent skill", async () => {
|
||||
// #given: non-existent skill name
|
||||
// #when: resolving content async
|
||||
const result = await resolveSkillContentAsync("definitely-not-a-skill-12345")
|
||||
|
||||
// #then: returns null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveMultipleSkillsAsync", () => {
|
||||
it("should resolve builtin skills", async () => {
|
||||
// #given: builtin skill names
|
||||
const skillNames = ["playwright", "frontend-ui-ux"]
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: all builtin skills resolved
|
||||
expect(result.resolved.size).toBe(2)
|
||||
expect(result.notFound).toEqual([])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer")
|
||||
})
|
||||
|
||||
it("should handle partial success with non-existent skills", async () => {
|
||||
// #given: mix of existing and non-existing skills
|
||||
const skillNames = ["playwright", "nonexistent-skill-12345"]
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: existing skills resolved, non-existing in notFound
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.notFound).toEqual(["nonexistent-skill-12345"])
|
||||
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
|
||||
})
|
||||
|
||||
it("should support git-master config injection", async () => {
|
||||
// #given: git-master skill with config override
|
||||
const skillNames = ["git-master"]
|
||||
const options = {
|
||||
gitMasterConfig: {
|
||||
commit_footer: false,
|
||||
include_co_authored_by: false,
|
||||
},
|
||||
}
|
||||
|
||||
// #when: resolving with git-master config
|
||||
const result = await resolveMultipleSkillsAsync(skillNames, options)
|
||||
|
||||
// #then: config values injected into template
|
||||
expect(result.resolved.size).toBe(1)
|
||||
expect(result.notFound).toEqual([])
|
||||
const gitMasterContent = result.resolved.get("git-master")
|
||||
expect(gitMasterContent).toContain("commit_footer")
|
||||
expect(gitMasterContent).toContain("DISABLED")
|
||||
})
|
||||
|
||||
it("should handle empty array", async () => {
|
||||
// #given: empty skill names
|
||||
const skillNames: string[] = []
|
||||
|
||||
// #when: resolving multiple skills async
|
||||
const result = await resolveMultipleSkillsAsync(skillNames)
|
||||
|
||||
// #then: empty results
|
||||
expect(result.resolved.size).toBe(0)
|
||||
expect(result.notFound).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,64 @@
|
||||
import { createBuiltinSkills } from "../builtin-skills/skills"
|
||||
import { discoverSkills } from "./loader"
|
||||
import type { LoadedSkill } from "./types"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { readFileSync } from "node:fs"
|
||||
import type { GitMasterConfig } from "../../config/schema"
|
||||
|
||||
export interface SkillResolutionOptions {
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
}
|
||||
|
||||
let cachedSkills: LoadedSkill[] | null = null
|
||||
|
||||
function clearSkillCache(): void {
|
||||
cachedSkills = null
|
||||
}
|
||||
|
||||
async function getAllSkills(): Promise<LoadedSkill[]> {
|
||||
if (cachedSkills) return cachedSkills
|
||||
|
||||
const [discoveredSkills, builtinSkillDefs] = await Promise.all([
|
||||
discoverSkills({ includeClaudeCodePaths: true }),
|
||||
Promise.resolve(createBuiltinSkills()),
|
||||
])
|
||||
|
||||
const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({
|
||||
name: skill.name,
|
||||
definition: {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
template: skill.template,
|
||||
model: skill.model,
|
||||
agent: skill.agent,
|
||||
subtask: skill.subtask,
|
||||
},
|
||||
scope: "builtin" as const,
|
||||
license: skill.license,
|
||||
compatibility: skill.compatibility,
|
||||
metadata: skill.metadata as Record<string, string> | undefined,
|
||||
allowedTools: skill.allowedTools,
|
||||
mcpConfig: skill.mcpConfig,
|
||||
}))
|
||||
|
||||
const discoveredNames = new Set(discoveredSkills.map((s) => s.name))
|
||||
const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name))
|
||||
|
||||
cachedSkills = [...discoveredSkills, ...uniqueBuiltins]
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
async function extractSkillTemplate(skill: LoadedSkill): Promise<string> {
|
||||
if (skill.path) {
|
||||
const content = readFileSync(skill.path, "utf-8")
|
||||
const { body } = parseFrontmatter(content)
|
||||
return body.trim()
|
||||
}
|
||||
return skill.definition.template || ""
|
||||
}
|
||||
|
||||
export { clearSkillCache, getAllSkills, extractSkillTemplate }
|
||||
|
||||
function injectGitMasterConfig(template: string, config?: GitMasterConfig): string {
|
||||
if (!config) return template
|
||||
|
||||
@@ -60,3 +114,53 @@ export function resolveMultipleSkills(skillNames: string[], options?: SkillResol
|
||||
|
||||
return { resolved, notFound }
|
||||
}
|
||||
|
||||
export async function resolveSkillContentAsync(
|
||||
skillName: string,
|
||||
options?: SkillResolutionOptions
|
||||
): Promise<string | null> {
|
||||
const allSkills = await getAllSkills()
|
||||
const skill = allSkills.find((s) => s.name === skillName)
|
||||
if (!skill) return null
|
||||
|
||||
const template = await extractSkillTemplate(skill)
|
||||
|
||||
if (skillName === "git-master" && options?.gitMasterConfig) {
|
||||
return injectGitMasterConfig(template, options.gitMasterConfig)
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
||||
export async function resolveMultipleSkillsAsync(
|
||||
skillNames: string[],
|
||||
options?: SkillResolutionOptions
|
||||
): Promise<{
|
||||
resolved: Map<string, string>
|
||||
notFound: string[]
|
||||
}> {
|
||||
const allSkills = await getAllSkills()
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
for (const skill of allSkills) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
|
||||
const resolved = new Map<string, string>()
|
||||
const notFound: string[] = []
|
||||
|
||||
for (const name of skillNames) {
|
||||
const skill = skillMap.get(name)
|
||||
if (skill) {
|
||||
const template = await extractSkillTemplate(skill)
|
||||
if (name === "git-master" && options?.gitMasterConfig) {
|
||||
resolved.set(name, injectGitMasterConfig(template, options.gitMasterConfig))
|
||||
} else {
|
||||
resolved.set(name, template)
|
||||
}
|
||||
} else {
|
||||
notFound.push(name)
|
||||
}
|
||||
}
|
||||
|
||||
return { resolved, notFound }
|
||||
}
|
||||
|
||||
@@ -3,11 +3,47 @@ import { SkillMcpManager } from "./manager"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
|
||||
|
||||
|
||||
// Mock the MCP SDK transports to avoid network calls
|
||||
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
|
||||
const mockHttpClose = mock(() => Promise.resolve())
|
||||
let lastTransportInstance: { url?: URL; options?: { requestInit?: RequestInit } } = {}
|
||||
|
||||
mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
|
||||
StreamableHTTPClientTransport: class MockStreamableHTTPClientTransport {
|
||||
constructor(public url: URL, public options?: { requestInit?: RequestInit }) {
|
||||
lastTransportInstance = { url, options }
|
||||
}
|
||||
async start() {
|
||||
await mockHttpConnect()
|
||||
}
|
||||
async close() {
|
||||
await mockHttpClose()
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
describe("SkillMcpManager", () => {
|
||||
let manager: SkillMcpManager
|
||||
|
||||
beforeEach(() => {
|
||||
manager = new SkillMcpManager()
|
||||
mockHttpConnect.mockClear()
|
||||
mockHttpClose.mockClear()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -15,34 +51,296 @@ describe("SkillMcpManager", () => {
|
||||
})
|
||||
|
||||
describe("getOrCreateClient", () => {
|
||||
it("throws error when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
describe("configuration validation", () => {
|
||||
it("throws error when neither url nor command is provided", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing required 'command' field/
|
||||
)
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/no valid connection configuration/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes both HTTP and stdio examples in error message", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/HTTP[\s\S]*Stdio/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes server and skill names in error message", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "custom-server",
|
||||
skillName: "custom-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/custom-server[\s\S]*custom-skill/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it("includes helpful error message with example when command is missing", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "my-mcp",
|
||||
skillName: "data-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {}
|
||||
describe("connection type detection", () => {
|
||||
it("detects HTTP connection from explicit type='http'", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "http-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "http",
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/my-mcp[\s\S]*data-skill[\s\S]*Example/
|
||||
)
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects HTTP connection from explicit type='sse'", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "sse-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "sse",
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects HTTP connection from url field when type is not specified", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "inferred-http",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects stdio connection from explicit type='stdio'", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "stdio-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*Command/
|
||||
)
|
||||
})
|
||||
|
||||
it("detects stdio connection from command field when type is not specified", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "inferred-stdio",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect[\s\S]*Command/
|
||||
)
|
||||
})
|
||||
|
||||
it("prefers explicit type over inferred type", async () => {
|
||||
// #given - has both url and command, but type is explicitly stdio
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mixed-config",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
url: "https://example.com/mcp", // should be ignored
|
||||
command: "node",
|
||||
args: ["-e", "process.exit(0)"],
|
||||
}
|
||||
|
||||
// #when / #then - should use stdio (show Command in error, not URL)
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Command: node/
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("HTTP connection", () => {
|
||||
it("throws error for invalid URL", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "bad-url-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "http",
|
||||
url: "not-a-valid-url",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/invalid URL/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes URL in HTTP connection error", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "http-error-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/https:\/\/nonexistent\.example\.com\/mcp/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful hints for HTTP connection failures", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "hint-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://nonexistent.example.com/mcp",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Hints[\s\S]*Verify the URL[\s\S]*authentication headers[\s\S]*MCP over HTTP/
|
||||
)
|
||||
})
|
||||
|
||||
it("calls mocked transport connect for HTTP connections", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "mock-test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
}
|
||||
|
||||
// #when
|
||||
try {
|
||||
await manager.getOrCreateClient(info, config)
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
|
||||
// #then - verify mock was called (transport was instantiated)
|
||||
// The connection attempt happens through the Client.connect() which
|
||||
// internally calls transport.start()
|
||||
expect(mockHttpConnect).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("stdio connection (backward compatibility)", () => {
|
||||
it("throws error when command is missing for stdio type", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "missing-command",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
type: "stdio",
|
||||
// command is missing
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/missing 'command' field/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes command in stdio connection error", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "nonexistent-command-xyz",
|
||||
args: ["--foo"],
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/nonexistent-command-xyz --foo/
|
||||
)
|
||||
})
|
||||
|
||||
it("includes helpful hints for stdio connection failures", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "test-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
command: "nonexistent-command",
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Hints[\s\S]*PATH[\s\S]*package exists/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -156,4 +454,52 @@ describe("SkillMcpManager", () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("HTTP headers handling", () => {
|
||||
it("accepts configuration with headers", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "auth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
},
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
// Headers are passed through to the transport
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
|
||||
// Verify headers were forwarded to transport
|
||||
expect(lastTransportInstance.options?.requestInit?.headers).toEqual({
|
||||
Authorization: "Bearer test-token",
|
||||
"X-Custom-Header": "custom-value",
|
||||
})
|
||||
})
|
||||
|
||||
it("works without headers (optional)", async () => {
|
||||
// #given
|
||||
const info: SkillMcpClientInfo = {
|
||||
serverName: "no-auth-server",
|
||||
skillName: "test-skill",
|
||||
sessionID: "session-1",
|
||||
}
|
||||
const config: ClaudeCodeMcpServer = {
|
||||
url: "https://example.com/mcp",
|
||||
// no headers
|
||||
}
|
||||
|
||||
// #when / #then - should fail at connection, not config validation
|
||||
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
|
||||
/Failed to connect/
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,60 @@
|
||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
/**
|
||||
* Connection type for a managed MCP client.
|
||||
* - "stdio": Local process via stdin/stdout
|
||||
* - "http": Remote server via HTTP (Streamable HTTP transport)
|
||||
*/
|
||||
type ConnectionType = "stdio" | "http"
|
||||
|
||||
interface ManagedClientBase {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
lastUsedAt: number
|
||||
connectionType: ConnectionType
|
||||
}
|
||||
|
||||
interface ManagedStdioClient extends ManagedClientBase {
|
||||
connectionType: "stdio"
|
||||
transport: StdioClientTransport
|
||||
}
|
||||
|
||||
interface ManagedHttpClient extends ManagedClientBase {
|
||||
connectionType: "http"
|
||||
transport: StreamableHTTPClientTransport
|
||||
}
|
||||
|
||||
type ManagedClient = ManagedStdioClient | ManagedHttpClient
|
||||
|
||||
/**
|
||||
* Determines connection type from MCP server configuration.
|
||||
* Priority: explicit type field > url presence > command presence
|
||||
*/
|
||||
function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null {
|
||||
// Explicit type takes priority
|
||||
if (config.type === "http" || config.type === "sse") {
|
||||
return "http"
|
||||
}
|
||||
if (config.type === "stdio") {
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
// Infer from available fields
|
||||
if (config.url) {
|
||||
return "http"
|
||||
}
|
||||
if (config.command) {
|
||||
return "stdio"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
@@ -98,18 +142,125 @@ export class SkillMcpManager {
|
||||
private async createClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const connectionType = getConnectionType(config)
|
||||
|
||||
if (!connectionType) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" has no valid connection configuration.\n\n` +
|
||||
`The MCP configuration in skill "${info.skillName}" must specify either:\n` +
|
||||
` - A URL for HTTP connection (remote MCP server)\n` +
|
||||
` - A command for stdio connection (local MCP process)\n\n` +
|
||||
`Examples:\n` +
|
||||
` HTTP:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` url: https://mcp.example.com/mcp\n` +
|
||||
` headers:\n` +
|
||||
` Authorization: Bearer \${API_KEY}\n\n` +
|
||||
` Stdio:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` command: npx\n` +
|
||||
` args: [-y, @some/mcp-server]`
|
||||
)
|
||||
}
|
||||
|
||||
if (connectionType === "http") {
|
||||
return this.createHttpClient(info, config)
|
||||
} else {
|
||||
return this.createStdioClient(info, config)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an HTTP-based MCP client using StreamableHTTPClientTransport.
|
||||
* Supports remote MCP servers with optional authentication headers.
|
||||
*/
|
||||
private async createHttpClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
|
||||
if (!config.url) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" is configured for HTTP but missing 'url' field.`
|
||||
)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(config.url)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" has invalid URL: ${config.url}\n\n` +
|
||||
`Expected a valid URL like: https://mcp.example.com/mcp`
|
||||
)
|
||||
}
|
||||
|
||||
this.registerProcessCleanup()
|
||||
|
||||
// Build request init with headers if provided
|
||||
const requestInit: RequestInit = {}
|
||||
if (config.headers && Object.keys(config.headers).length > 0) {
|
||||
requestInit.headers = config.headers
|
||||
}
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(url, {
|
||||
requestInit: Object.keys(requestInit).length > 0 ? requestInit : undefined,
|
||||
})
|
||||
|
||||
const client = new Client(
|
||||
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
|
||||
{ capabilities: {} }
|
||||
)
|
||||
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
try {
|
||||
await transport.close()
|
||||
} catch {
|
||||
// Transport may already be closed
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
`URL: ${config.url}\n` +
|
||||
`Reason: ${errorMessage}\n\n` +
|
||||
`Hints:\n` +
|
||||
` - Verify the URL is correct and the server is running\n` +
|
||||
` - Check if authentication headers are required\n` +
|
||||
` - Ensure the server supports MCP over HTTP`
|
||||
)
|
||||
}
|
||||
|
||||
const managedClient: ManagedHttpClient = {
|
||||
client,
|
||||
transport,
|
||||
skillName: info.skillName,
|
||||
lastUsedAt: Date.now(),
|
||||
connectionType: "http",
|
||||
}
|
||||
this.clients.set(key, managedClient)
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a stdio-based MCP client using StdioClientTransport.
|
||||
* Spawns a local process and communicates via stdin/stdout.
|
||||
*/
|
||||
private async createStdioClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
): Promise<Client> {
|
||||
const key = this.getClientKey(info)
|
||||
|
||||
if (!config.command) {
|
||||
throw new Error(
|
||||
`MCP server "${info.serverName}" is missing required 'command' field.\n\n` +
|
||||
`The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` +
|
||||
`Example:\n` +
|
||||
` mcp:\n` +
|
||||
` ${info.serverName}:\n` +
|
||||
` command: npx\n` +
|
||||
` args: [-y, @some/mcp-server]`
|
||||
`MCP server "${info.serverName}" is configured for stdio but missing 'command' field.`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -153,7 +304,14 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() })
|
||||
const managedClient: ManagedStdioClient = {
|
||||
client,
|
||||
transport,
|
||||
skillName: info.skillName,
|
||||
lastUsedAt: Date.now(),
|
||||
connectionType: "stdio",
|
||||
}
|
||||
this.clients.set(key, managedClient)
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { TaskToastManager, getTaskToastManager, initTaskToastManager } from "./manager"
|
||||
export type { TrackedTask, TaskStatus, TaskToastOptions } from "./types"
|
||||
export type { TrackedTask, TaskStatus, TaskToastOptions, ModelFallbackInfo } from "./types"
|
||||
|
||||
@@ -142,4 +142,109 @@ describe("TaskToastManager", () => {
|
||||
expect(call.body.message).toContain("Running (1):")
|
||||
})
|
||||
})
|
||||
|
||||
describe("model fallback info in toast message", () => {
|
||||
test("should display warning when model falls back to category-default", () => {
|
||||
// #given - a task with model fallback to category-default
|
||||
const task = {
|
||||
id: "task_1",
|
||||
description: "Task with category default model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "google/gemini-3-pro-preview", type: "category-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with model info
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("google/gemini-3-pro-preview")
|
||||
expect(call.body.message).toContain("(category default)")
|
||||
})
|
||||
|
||||
test("should display warning when model falls back to system-default", () => {
|
||||
// #given - a task with model fallback to system-default
|
||||
const task = {
|
||||
id: "task_1b",
|
||||
description: "Task with system default model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "anthropic/claude-sonnet-4-5", type: "system-default" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with model info
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("anthropic/claude-sonnet-4-5")
|
||||
expect(call.body.message).toContain("(system default)")
|
||||
})
|
||||
|
||||
test("should display warning when model is inherited from parent", () => {
|
||||
// #given - a task with inherited model
|
||||
const task = {
|
||||
id: "task_2",
|
||||
description: "Task with inherited model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "cliproxy/claude-opus-4-5", type: "inherited" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should show warning with inherited model
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).toContain("⚠️")
|
||||
expect(call.body.message).toContain("cliproxy/claude-opus-4-5")
|
||||
expect(call.body.message).toContain("(inherited)")
|
||||
})
|
||||
|
||||
test("should not display model info when user-defined", () => {
|
||||
// #given - a task with user-defined model
|
||||
const task = {
|
||||
id: "task_3",
|
||||
description: "Task with user model",
|
||||
agent: "Sisyphus-Junior",
|
||||
isBackground: false,
|
||||
modelInfo: { model: "my-provider/my-model", type: "user-defined" as const },
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show model warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("⚠️ Model:")
|
||||
expect(call.body.message).not.toContain("(inherited)")
|
||||
expect(call.body.message).not.toContain("(category default)")
|
||||
expect(call.body.message).not.toContain("(system default)")
|
||||
})
|
||||
|
||||
test("should not display model info when not provided", () => {
|
||||
// #given - a task without model info
|
||||
const task = {
|
||||
id: "task_4",
|
||||
description: "Task without model info",
|
||||
agent: "explore",
|
||||
isBackground: true,
|
||||
}
|
||||
|
||||
// #when - addTask is called
|
||||
toastManager.addTask(task)
|
||||
|
||||
// #then - toast should NOT show model warning
|
||||
expect(mockClient.tui.showToast).toHaveBeenCalled()
|
||||
const call = mockClient.tui.showToast.mock.calls[0][0]
|
||||
expect(call.body.message).not.toContain("⚠️ Model:")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { TrackedTask, TaskStatus } from "./types"
|
||||
import type { TrackedTask, TaskStatus, ModelFallbackInfo } from "./types"
|
||||
import type { ConcurrencyManager } from "../background-agent/concurrency"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
@@ -25,6 +25,7 @@ export class TaskToastManager {
|
||||
isBackground: boolean
|
||||
status?: TaskStatus
|
||||
skills?: string[]
|
||||
modelInfo?: ModelFallbackInfo
|
||||
}): void {
|
||||
const trackedTask: TrackedTask = {
|
||||
id: task.id,
|
||||
@@ -34,6 +35,7 @@ export class TaskToastManager {
|
||||
startedAt: new Date(),
|
||||
isBackground: task.isBackground,
|
||||
skills: task.skills,
|
||||
modelInfo: task.modelInfo,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, trackedTask)
|
||||
@@ -105,6 +107,19 @@ export class TaskToastManager {
|
||||
|
||||
const lines: string[] = []
|
||||
|
||||
// Show model fallback warning for the new task if applicable
|
||||
if (newTask.modelInfo && newTask.modelInfo.type !== "user-defined") {
|
||||
const icon = "⚠️"
|
||||
const suffixMap: Partial<Record<ModelFallbackInfo["type"], string>> = {
|
||||
inherited: " (inherited)",
|
||||
"category-default": " (category default)",
|
||||
"system-default": " (system default)",
|
||||
}
|
||||
const suffix = suffixMap[newTask.modelInfo.type] ?? ""
|
||||
lines.push(`${icon} Model: ${newTask.modelInfo.model}${suffix}`)
|
||||
lines.push("")
|
||||
}
|
||||
|
||||
if (running.length > 0) {
|
||||
lines.push(`Running (${running.length}):${concurrencyInfo}`)
|
||||
for (const task of running) {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
export type TaskStatus = "running" | "queued" | "completed" | "error"
|
||||
|
||||
export interface ModelFallbackInfo {
|
||||
model: string
|
||||
type: "user-defined" | "inherited" | "category-default" | "system-default"
|
||||
}
|
||||
|
||||
export interface TrackedTask {
|
||||
id: string
|
||||
description: string
|
||||
@@ -8,6 +13,7 @@ export interface TrackedTask {
|
||||
startedAt: Date
|
||||
isBackground: boolean
|
||||
skills?: string[]
|
||||
modelInfo?: ModelFallbackInfo
|
||||
}
|
||||
|
||||
export interface TaskToastOptions {
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
## STRUCTURE
|
||||
```
|
||||
hooks/
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit (555 lines)
|
||||
├── sisyphus-orchestrator/ # Main orchestration & agent delegation (677 lines)
|
||||
├── sisyphus-orchestrator/ # Main orchestration & agent delegation (684 lines)
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit (554 lines)
|
||||
├── todo-continuation-enforcer.ts # Force completion of [ ] items (445 lines)
|
||||
├── ralph-loop/ # Self-referential dev loop (364 lines)
|
||||
├── claude-code-hooks/ # settings.json hook compatibility layer
|
||||
├── comment-checker/ # Prevents AI slop/excessive comments
|
||||
@@ -23,7 +24,6 @@ hooks/
|
||||
├── start-work/ # Initializes work sessions (ulw/ulw)
|
||||
├── think-mode/ # Dynamic thinking budget adjustment
|
||||
├── background-notification/ # OS notification on task completion
|
||||
├── todo-continuation-enforcer.ts # Force completion of [ ] items
|
||||
└── tool-output-truncator.ts # Prevents context bloat from verbose tools
|
||||
```
|
||||
|
||||
|
||||
@@ -320,7 +320,6 @@ export async function executeCompact(
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ const DEFAULT_PROTECTED_TOOLS = new Set([
|
||||
"todowrite",
|
||||
"todoread",
|
||||
"lsp_rename",
|
||||
"lsp_code_action_resolve",
|
||||
"session_read",
|
||||
"session_write",
|
||||
"session_search",
|
||||
|
||||
68
src/hooks/comment-checker/cli.test.ts
Normal file
68
src/hooks/comment-checker/cli.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, test, expect, beforeEach, mock } from "bun:test"
|
||||
|
||||
describe("comment-checker CLI path resolution", () => {
|
||||
describe("lazy initialization", () => {
|
||||
// #given module is imported
|
||||
// #when COMMENT_CHECKER_CLI_PATH is accessed
|
||||
// #then findCommentCheckerPathSync should NOT have been called during import
|
||||
|
||||
test("getCommentCheckerPathSync should be lazy - not called on module import", async () => {
|
||||
// #given a fresh module import
|
||||
// We need to verify that importing the module doesn't immediately call findCommentCheckerPathSync
|
||||
|
||||
// #when we import the module
|
||||
const cliModule = await import("./cli")
|
||||
|
||||
// #then getCommentCheckerPathSync should exist and be callable
|
||||
expect(typeof cliModule.getCommentCheckerPathSync).toBe("function")
|
||||
|
||||
// The key test: calling getCommentCheckerPathSync should work
|
||||
// (we can't easily test that it wasn't called on import without mocking,
|
||||
// but we can verify the function exists and returns expected types)
|
||||
const result = cliModule.getCommentCheckerPathSync()
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
|
||||
test("getCommentCheckerPathSync should cache result after first call", async () => {
|
||||
// #given getCommentCheckerPathSync is called once
|
||||
const cliModule = await import("./cli")
|
||||
const firstResult = cliModule.getCommentCheckerPathSync()
|
||||
|
||||
// #when called again
|
||||
const secondResult = cliModule.getCommentCheckerPathSync()
|
||||
|
||||
// #then should return same cached result
|
||||
expect(secondResult).toBe(firstResult)
|
||||
})
|
||||
|
||||
test("COMMENT_CHECKER_CLI_PATH export should not exist (removed for lazy loading)", async () => {
|
||||
// #given the cli module
|
||||
const cliModule = await import("./cli")
|
||||
|
||||
// #when checking for COMMENT_CHECKER_CLI_PATH
|
||||
// #then it should not exist (replaced with lazy getter)
|
||||
expect("COMMENT_CHECKER_CLI_PATH" in cliModule).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("runCommentChecker", () => {
|
||||
test("should use getCommentCheckerPathSync for fallback path resolution", async () => {
|
||||
// #given runCommentChecker is called without explicit path
|
||||
const { runCommentChecker } = await import("./cli")
|
||||
|
||||
// #when called with input containing no comments
|
||||
const result = await runCommentChecker({
|
||||
session_id: "test",
|
||||
tool_name: "Write",
|
||||
transcript_path: "",
|
||||
cwd: "/tmp",
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: { file_path: "/tmp/test.ts", content: "const x = 1" },
|
||||
})
|
||||
|
||||
// #then should return CheckResult type (binary may or may not exist)
|
||||
expect(typeof result.hasComments).toBe("boolean")
|
||||
expect(typeof result.message).toBe("string")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -121,9 +121,6 @@ export function startBackgroundInit(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export for backwards compatibility (sync, no download)
|
||||
export const COMMENT_CHECKER_CLI_PATH = findCommentCheckerPathSync()
|
||||
|
||||
export interface HookInput {
|
||||
session_id: string
|
||||
tool_name: string
|
||||
@@ -152,7 +149,7 @@ export interface CheckResult {
|
||||
* @param customPrompt Optional custom prompt to replace default warning message
|
||||
*/
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> {
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? getCommentCheckerPathSync()
|
||||
|
||||
if (!binaryPath) {
|
||||
debugLog("comment-checker binary not found")
|
||||
|
||||
@@ -30,3 +30,4 @@ export { createPrometheusMdOnlyHook } from "./prometheus-md-only";
|
||||
export { createTaskResumeInfoHook } from "./task-resume-info";
|
||||
export { createStartWorkHook } from "./start-work";
|
||||
export { createSisyphusOrchestratorHook } from "./sisyphus-orchestrator";
|
||||
export { createSisyphusTaskRetryHook } from "./sisyphus-task-retry";
|
||||
|
||||
@@ -192,7 +192,7 @@ THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTIN
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string | ((agentName?: string) => string) }> = [
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
pattern: /\b(ultrawork|ulw)\b/i,
|
||||
message: getUltraworkMessage,
|
||||
},
|
||||
// SEARCH: EN/KO/JP/CN/VN
|
||||
|
||||
@@ -93,16 +93,18 @@ describe("keyword-detector registers to ContextCollector", () => {
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
@@ -233,3 +235,100 @@ describe("keyword-detector session filtering", () => {
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector word boundary", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should NOT trigger ultrawork on partial matches like 'StatefulWidget' containing 'ulw'", async () => {
|
||||
// #given - text contains 'ulw' as part of another word (StatefulWidget)
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "refactor the StatefulWidget component" }],
|
||||
}
|
||||
|
||||
// #when - message with partial 'ulw' match is processed
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should NOT be triggered
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should trigger ultrawork on standalone 'ulw' keyword", async () => {
|
||||
// #given - text contains standalone 'ulw'
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ulw do this task" }],
|
||||
}
|
||||
|
||||
// #when - message with standalone 'ulw' is processed
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should be triggered
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should NOT trigger ultrawork on file references containing 'ulw' substring", async () => {
|
||||
// #given - file reference contains 'ulw' as substring
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "@StatefulWidget.tsx please review this file" }],
|
||||
}
|
||||
|
||||
// #when - message referencing file with 'ulw' substring is processed
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should NOT be triggered
|
||||
expect(output.message.variant).toBeUndefined()
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
|
||||
|
||||
describe("non-interactive-env hook", () => {
|
||||
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
|
||||
|
||||
let originalPlatform: NodeJS.Platform
|
||||
let originalEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
originalEnv = {
|
||||
SHELL: process.env.SHELL,
|
||||
PSModulePath: process.env.PSModulePath,
|
||||
}
|
||||
// #given clean Unix-like environment for all tests
|
||||
// This prevents CI environments (which may have PSModulePath set) from
|
||||
// triggering PowerShell detection in tests that expect Unix behavior
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/bash"
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
} else {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("git command modification", () => {
|
||||
test("#given git command #when hook executes #then prepends export statement", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
@@ -147,4 +174,147 @@ describe("non-interactive-env hook", () => {
|
||||
expect(output.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-platform shell support", () => {
|
||||
test("#given macOS platform #when git command executes #then uses unix export syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain(";")
|
||||
expect(cmd).not.toContain("$env:")
|
||||
expect(cmd).not.toContain("set ")
|
||||
})
|
||||
|
||||
test("#given Linux platform #when git command executes #then uses unix export syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git commit")
|
||||
})
|
||||
|
||||
test("#given Windows with PowerShell #when git command executes #then uses powershell $env syntax", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("$env:")
|
||||
expect(cmd).toContain("; git status")
|
||||
expect(cmd).not.toStartWith("export ")
|
||||
expect(cmd).not.toContain("set ")
|
||||
})
|
||||
|
||||
test("#given Windows without PowerShell #when git command executes #then uses cmd set syntax", async () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git log" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("set ")
|
||||
expect(cmd).toContain("&&")
|
||||
expect(cmd).not.toStartWith("export ")
|
||||
expect(cmd).not.toContain("$env:")
|
||||
})
|
||||
|
||||
test("#given PowerShell #when values contain quotes #then escapes correctly", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toMatch(/\$env:\w+='[^']*'/)
|
||||
})
|
||||
|
||||
test("#given cmd.exe #when values contain spaces #then escapes correctly", async () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toMatch(/set \w+="[^"]*"/)
|
||||
})
|
||||
|
||||
test("#given PowerShell #when chained git commands #then env vars apply to all commands", async () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git add file && git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("$env:")
|
||||
expect(cmd).toContain("; git add file && git commit")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { isNonInteractive } from "./detector"
|
||||
import { log, detectShellType, buildEnvPrefix } from "../../shared"
|
||||
|
||||
export * from "./constants"
|
||||
export * from "./detector"
|
||||
@@ -19,35 +20,6 @@ function detectBannedCommand(command: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in VAR=value prefix.
|
||||
* Wraps in single quotes if contains special chars.
|
||||
*/
|
||||
function shellEscape(value: string): string {
|
||||
// Empty string needs quotes
|
||||
if (value === "") return "''"
|
||||
// If contains special chars, wrap in single quotes (escape existing single quotes)
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Build export statement for environment variables.
|
||||
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
|
||||
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
|
||||
*
|
||||
* Previous approach used VAR=value prefix which only applies to the first command.
|
||||
* OpenCode's bash tool ignores args.env, so we must prepend to command.
|
||||
*/
|
||||
function buildEnvPrefix(env: Record<string, string>): string {
|
||||
const exports = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||
.join(" ")
|
||||
return `export ${exports};`
|
||||
}
|
||||
|
||||
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
@@ -74,11 +46,12 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
|
||||
// ignoring any args.env we might set. Prepend export statement to command.
|
||||
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
|
||||
// in a chain (e.g., `git add file && git rebase --continue`).
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
|
||||
if (!isNonInteractive()) {
|
||||
return
|
||||
}
|
||||
|
||||
const shellType = detectShellType()
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||
|
||||
@@ -684,7 +684,8 @@ describe("ralph-loop", () => {
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
test("should not hang when session.messages() times out", async () => {
|
||||
// FIXME: Flaky in CI - times out intermittently
|
||||
test.skip("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
...createMockPluginInput(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { log } from "../../shared/logger"
|
||||
import { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||
import {
|
||||
@@ -9,6 +10,18 @@ import {
|
||||
} from "./constants"
|
||||
import type { RalphLoopState, RalphLoopOptions } from "./types"
|
||||
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
@@ -302,9 +315,36 @@ export function createRalphLoopHook(
|
||||
.catch(() => {})
|
||||
|
||||
try {
|
||||
let agent: string | undefined
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { agent?: string; model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
agent = info.agent
|
||||
model = info.model
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agent = currentMessage?.agent
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
...(agent !== undefined ? { agent } : {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: continuationPrompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
|
||||
@@ -140,7 +140,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
|
||||
// #then - standalone verification reminder appended
|
||||
expect(output.output).toContain("Task completed successfully")
|
||||
expect(output.output).toContain("MANDATORY VERIFICATION")
|
||||
expect(output.output).toContain("MANDATORY:")
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
@@ -179,7 +179,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
expect(output.output).toContain("Task completed successfully")
|
||||
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
|
||||
expect(output.output).toContain("test-plan")
|
||||
expect(output.output).toContain("SUBAGENTS LIE")
|
||||
expect(output.output).toContain("LIE")
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
@@ -217,7 +217,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then - output transformed even when complete (shows 2/2 done)
|
||||
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
|
||||
expect(output.output).toContain("2/2 done")
|
||||
expect(output.output).toContain("0 left")
|
||||
expect(output.output).toContain("0 remaining")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
@@ -327,7 +327,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then - output should contain plan name and progress
|
||||
expect(output.output).toContain("my-feature")
|
||||
expect(output.output).toContain("1/3 done")
|
||||
expect(output.output).toContain("2 left")
|
||||
expect(output.output).toContain("2 remaining")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
@@ -364,7 +364,7 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then - should include resume instructions and verification
|
||||
expect(output.output).toContain("sisyphus_task(resume=")
|
||||
expect(output.output).toContain("[x]")
|
||||
expect(output.output).toContain("MANDATORY VERIFICATION")
|
||||
expect(output.output).toContain("MANDATORY:")
|
||||
|
||||
cleanupMessageStorage(sessionID)
|
||||
})
|
||||
|
||||
@@ -63,34 +63,45 @@ RULES:
|
||||
- Do not stop until all tasks are complete
|
||||
- If blocked, document the blocker and move to the next task`
|
||||
|
||||
const VERIFICATION_REMINDER = `**MANDATORY VERIFICATION - SUBAGENTS LIE**
|
||||
const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW**
|
||||
|
||||
Subagents FREQUENTLY claim completion when:
|
||||
- Tests are actually FAILING
|
||||
- Code has type/lint ERRORS
|
||||
- Implementation is INCOMPLETE
|
||||
- Patterns were NOT followed
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**YOU MUST VERIFY EVERYTHING YOURSELF:**
|
||||
⚠️ CRITICAL: Subagents FREQUENTLY LIE about completion.
|
||||
Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done".
|
||||
|
||||
1. Run \`lsp_diagnostics\` on changed files - Must be CLEAN
|
||||
2. Run tests yourself - Must PASS (not "agent said it passed")
|
||||
3. Read the actual code - Must match requirements
|
||||
4. Check build/typecheck - Must succeed
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
DO NOT TRUST THE AGENT'S SELF-REPORT.
|
||||
VERIFY EACH CLAIM WITH YOUR OWN TOOL CALLS.
|
||||
**STEP 1: VERIFY WITH YOUR OWN TOOL CALLS (DO THIS NOW)**
|
||||
|
||||
**HANDS-ON QA REQUIRED (after ALL tasks complete):**
|
||||
Run these commands YOURSELF - do NOT trust agent's claims:
|
||||
1. \`lsp_diagnostics\` on changed files → Must be CLEAN
|
||||
2. \`bash\` to run tests → Must PASS
|
||||
3. \`bash\` to run build/typecheck → Must succeed
|
||||
4. \`Read\` the actual code → Must match requirements
|
||||
|
||||
| Deliverable Type | Verification Tool | Action |
|
||||
|------------------|-------------------|--------|
|
||||
| **Frontend/UI** | \`/playwright\` skill | Navigate, interact, screenshot evidence |
|
||||
| **TUI/CLI** | \`interactive_bash\` (tmux) | Run interactively, verify output |
|
||||
| **API/Backend** | \`bash\` with curl | Send requests, verify responses |
|
||||
**STEP 2: DETERMINE IF HANDS-ON QA IS NEEDED**
|
||||
|
||||
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages, integration problems.
|
||||
**FAILURE TO DO HANDS-ON QA = INCOMPLETE WORK.**`
|
||||
| Deliverable Type | QA Method | Tool |
|
||||
|------------------|-----------|------|
|
||||
| **Frontend/UI** | Browser interaction | \`/playwright\` skill |
|
||||
| **TUI/CLI** | Run interactively | \`interactive_bash\` (tmux) |
|
||||
| **API/Backend** | Send real requests | \`bash\` with curl |
|
||||
|
||||
Static analysis CANNOT catch: visual bugs, animation issues, user flow breakages.
|
||||
|
||||
**STEP 3: IF QA IS NEEDED - ADD TO TODO IMMEDIATELY**
|
||||
|
||||
\`\`\`
|
||||
todowrite([
|
||||
{ id: "qa-X", content: "HANDS-ON QA: [specific verification action]", status: "pending", priority: "high" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**BLOCKING: DO NOT proceed to next task until Steps 1-3 are complete.**
|
||||
**FAILURE TO DO QA = INCOMPLETE WORK = USER WILL REJECT.**`
|
||||
|
||||
const ORCHESTRATOR_DELEGATION_REQUIRED = `
|
||||
|
||||
@@ -183,20 +194,38 @@ function buildOrchestratorReminder(planName: string, progress: { total: number;
|
||||
return `
|
||||
---
|
||||
|
||||
**State:** Plan: ${planName} | ${progress.completed}/${progress.total} done, ${remaining} left
|
||||
**BOULDER STATE:** Plan: \`${planName}\` | ✅ ${progress.completed}/${progress.total} done | ⏳ ${remaining} remaining
|
||||
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
ALL pass? → commit atomic unit, mark \`[x]\`, next task.`
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**AFTER VERIFICATION PASSES - YOUR NEXT ACTIONS (IN ORDER):**
|
||||
|
||||
1. **COMMIT** atomic unit (only verified changes)
|
||||
2. **MARK** \`[x]\` in plan file for completed task
|
||||
3. **PROCEED** to next task immediately
|
||||
|
||||
**DO NOT STOP. ${remaining} tasks remain. Keep bouldering.**`
|
||||
}
|
||||
|
||||
function buildStandaloneVerificationReminder(sessionId: string): string {
|
||||
return `
|
||||
---
|
||||
|
||||
${buildVerificationReminder(sessionId)}`
|
||||
${buildVerificationReminder(sessionId)}
|
||||
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
**AFTER VERIFICATION - CHECK YOUR TODO LIST:**
|
||||
|
||||
1. Run \`todoread\` to see remaining tasks
|
||||
2. If QA tasks exist → execute them BEFORE marking complete
|
||||
3. Mark completed tasks → proceed to next pending task
|
||||
|
||||
**NO TODO = NO TRACKING = INCOMPLETE WORK. Use todowrite aggressively.**`
|
||||
}
|
||||
|
||||
function extractSessionIdFromOutput(output: string): string {
|
||||
@@ -407,10 +436,32 @@ export function createSisyphusOrchestratorHook(
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
|
||||
|
||||
let model: { providerID: string; modelID: string } | undefined
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({ path: { id: sessionID } })
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: { model?: { providerID: string; modelID: string } }
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const msgModel = messages[i].info?.model
|
||||
if (msgModel?.providerID && msgModel?.modelID) {
|
||||
model = { providerID: msgModel.providerID, modelID: msgModel.modelID }
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
model = currentMessage?.model?.providerID && currentMessage?.model?.modelID
|
||||
? { providerID: currentMessage.model.providerID, modelID: currentMessage.model.modelID }
|
||||
: undefined
|
||||
}
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: "orchestrator-sisyphus",
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
|
||||
119
src/hooks/sisyphus-task-retry/index.test.ts
Normal file
119
src/hooks/sisyphus-task-retry/index.test.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
SISYPHUS_TASK_ERROR_PATTERNS,
|
||||
detectSisyphusTaskError,
|
||||
buildRetryGuidance,
|
||||
} from "./index"
|
||||
|
||||
describe("sisyphus-task-retry", () => {
|
||||
describe("SISYPHUS_TASK_ERROR_PATTERNS", () => {
|
||||
// #given error patterns are defined
|
||||
// #then should include all known sisyphus_task error types
|
||||
it("should contain all known error patterns", () => {
|
||||
expect(SISYPHUS_TASK_ERROR_PATTERNS.length).toBeGreaterThan(5)
|
||||
|
||||
const patternTexts = SISYPHUS_TASK_ERROR_PATTERNS.map(p => p.pattern)
|
||||
expect(patternTexts).toContain("run_in_background")
|
||||
expect(patternTexts).toContain("skills")
|
||||
expect(patternTexts).toContain("category OR subagent_type")
|
||||
expect(patternTexts).toContain("Unknown category")
|
||||
expect(patternTexts).toContain("Unknown agent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectSisyphusTaskError", () => {
|
||||
// #given tool output with run_in_background error
|
||||
// #when detecting error
|
||||
// #then should return matching error info
|
||||
it("should detect run_in_background missing error", () => {
|
||||
const output = "❌ Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation."
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("missing_run_in_background")
|
||||
})
|
||||
|
||||
it("should detect skills missing error", () => {
|
||||
const output = "❌ Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills needed."
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("missing_skills")
|
||||
})
|
||||
|
||||
it("should detect category/subagent mutual exclusion error", () => {
|
||||
const output = "❌ Invalid arguments: Provide EITHER category OR subagent_type, not both."
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("mutual_exclusion")
|
||||
})
|
||||
|
||||
it("should detect unknown category error", () => {
|
||||
const output = '❌ Unknown category: "invalid-cat". Available: visual-engineering, ultrabrain, quick'
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("unknown_category")
|
||||
})
|
||||
|
||||
it("should detect unknown agent error", () => {
|
||||
const output = '❌ Unknown agent: "fake-agent". Available agents: explore, librarian, oracle'
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.errorType).toBe("unknown_agent")
|
||||
})
|
||||
|
||||
it("should return null for successful output", () => {
|
||||
const output = "Background task launched.\n\nTask ID: bg_12345\nSession ID: ses_abc"
|
||||
|
||||
const result = detectSisyphusTaskError(output)
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildRetryGuidance", () => {
|
||||
// #given detected error
|
||||
// #when building retry guidance
|
||||
// #then should return actionable fix instructions
|
||||
it("should provide fix for missing run_in_background", () => {
|
||||
const errorInfo = { errorType: "missing_run_in_background", originalOutput: "" }
|
||||
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
|
||||
expect(guidance).toContain("run_in_background")
|
||||
expect(guidance).toContain("REQUIRED")
|
||||
})
|
||||
|
||||
it("should provide fix for unknown category with available list", () => {
|
||||
const errorInfo = {
|
||||
errorType: "unknown_category",
|
||||
originalOutput: '❌ Unknown category: "bad". Available: visual-engineering, ultrabrain'
|
||||
}
|
||||
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
|
||||
expect(guidance).toContain("visual-engineering")
|
||||
expect(guidance).toContain("ultrabrain")
|
||||
})
|
||||
|
||||
it("should provide fix for unknown agent with available list", () => {
|
||||
const errorInfo = {
|
||||
errorType: "unknown_agent",
|
||||
originalOutput: '❌ Unknown agent: "fake". Available agents: explore, oracle'
|
||||
}
|
||||
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
|
||||
expect(guidance).toContain("explore")
|
||||
expect(guidance).toContain("oracle")
|
||||
})
|
||||
})
|
||||
})
|
||||
136
src/hooks/sisyphus-task-retry/index.ts
Normal file
136
src/hooks/sisyphus-task-retry/index.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export interface SisyphusTaskErrorPattern {
|
||||
pattern: string
|
||||
errorType: string
|
||||
fixHint: string
|
||||
}
|
||||
|
||||
export const SISYPHUS_TASK_ERROR_PATTERNS: SisyphusTaskErrorPattern[] = [
|
||||
{
|
||||
pattern: "run_in_background",
|
||||
errorType: "missing_run_in_background",
|
||||
fixHint: "Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)",
|
||||
},
|
||||
{
|
||||
pattern: "skills",
|
||||
errorType: "missing_skills",
|
||||
fixHint: "Add skills=[] parameter (empty array if no skills needed)",
|
||||
},
|
||||
{
|
||||
pattern: "category OR subagent_type",
|
||||
errorType: "mutual_exclusion",
|
||||
fixHint: "Provide ONLY one of: category (e.g., 'general', 'quick') OR subagent_type (e.g., 'oracle', 'explore')",
|
||||
},
|
||||
{
|
||||
pattern: "Must provide either category or subagent_type",
|
||||
errorType: "missing_category_or_agent",
|
||||
fixHint: "Add either category='general' OR subagent_type='explore'",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown category",
|
||||
errorType: "unknown_category",
|
||||
fixHint: "Use a valid category from the Available list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Agent name cannot be empty",
|
||||
errorType: "empty_agent",
|
||||
fixHint: "Provide a non-empty subagent_type value",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown agent",
|
||||
errorType: "unknown_agent",
|
||||
fixHint: "Use a valid agent from the Available agents list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Cannot call primary agent",
|
||||
errorType: "primary_agent",
|
||||
fixHint: "Primary agents cannot be called via sisyphus_task. Use a subagent like 'explore', 'oracle', or 'librarian'",
|
||||
},
|
||||
{
|
||||
pattern: "Skills not found",
|
||||
errorType: "unknown_skills",
|
||||
fixHint: "Use valid skill names from the Available list in the error message",
|
||||
},
|
||||
]
|
||||
|
||||
export interface DetectedError {
|
||||
errorType: string
|
||||
originalOutput: string
|
||||
}
|
||||
|
||||
export function detectSisyphusTaskError(output: string): DetectedError | null {
|
||||
if (!output.includes("❌")) return null
|
||||
|
||||
for (const errorPattern of SISYPHUS_TASK_ERROR_PATTERNS) {
|
||||
if (output.includes(errorPattern.pattern)) {
|
||||
return {
|
||||
errorType: errorPattern.errorType,
|
||||
originalOutput: output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractAvailableList(output: string): string | null {
|
||||
const availableMatch = output.match(/Available[^:]*:\s*(.+)$/m)
|
||||
return availableMatch ? availableMatch[1].trim() : null
|
||||
}
|
||||
|
||||
export function buildRetryGuidance(errorInfo: DetectedError): string {
|
||||
const pattern = SISYPHUS_TASK_ERROR_PATTERNS.find(
|
||||
(p) => p.errorType === errorInfo.errorType
|
||||
)
|
||||
|
||||
if (!pattern) {
|
||||
return `[sisyphus_task ERROR] Fix the error and retry with correct parameters.`
|
||||
}
|
||||
|
||||
let guidance = `
|
||||
[sisyphus_task CALL FAILED - IMMEDIATE RETRY REQUIRED]
|
||||
|
||||
**Error Type**: ${errorInfo.errorType}
|
||||
**Fix**: ${pattern.fixHint}
|
||||
`
|
||||
|
||||
const availableList = extractAvailableList(errorInfo.originalOutput)
|
||||
if (availableList) {
|
||||
guidance += `\n**Available Options**: ${availableList}\n`
|
||||
}
|
||||
|
||||
guidance += `
|
||||
**Action**: Retry sisyphus_task NOW with corrected parameters.
|
||||
|
||||
Example of CORRECT call:
|
||||
\`\`\`
|
||||
sisyphus_task(
|
||||
description="Task description",
|
||||
prompt="Detailed prompt...",
|
||||
category="general", // OR subagent_type="explore"
|
||||
run_in_background=false,
|
||||
skills=[]
|
||||
)
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
return guidance
|
||||
}
|
||||
|
||||
export function createSisyphusTaskRetryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "sisyphus_task") return
|
||||
|
||||
const errorInfo = detectSisyphusTaskError(output.output)
|
||||
if (errorInfo) {
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
output.output += `\n${guidance}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -236,5 +236,148 @@ describe("start-work hook", () => {
|
||||
expect(output.parts[0].text).toContain("Ask the user")
|
||||
expect(output.parts[0].text).not.toContain("Which plan would you like to work on?")
|
||||
})
|
||||
|
||||
test("should select explicitly specified plan name from user-request, ignoring existing boulder state", async () => {
|
||||
// #given - existing boulder state pointing to old plan
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
// Old plan (in boulder state)
|
||||
const oldPlanPath = join(plansDir, "old-plan.md")
|
||||
writeFileSync(oldPlanPath, "# Old Plan\n- [ ] Old Task 1")
|
||||
|
||||
// New plan (user wants this one)
|
||||
const newPlanPath = join(plansDir, "new-plan.md")
|
||||
writeFileSync(newPlanPath, "# New Plan\n- [ ] New Task 1")
|
||||
|
||||
// Set up stale boulder state pointing to old plan
|
||||
const staleState: BoulderState = {
|
||||
active_plan: oldPlanPath,
|
||||
started_at: "2026-01-01T10:00:00Z",
|
||||
session_ids: ["old-session"],
|
||||
plan_name: "old-plan",
|
||||
}
|
||||
writeBoulderState(TEST_DIR, staleState)
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
new-plan
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when - user explicitly specifies new-plan
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should select new-plan, NOT resume old-plan
|
||||
expect(output.parts[0].text).toContain("new-plan")
|
||||
expect(output.parts[0].text).not.toContain("RESUMING")
|
||||
expect(output.parts[0].text).not.toContain("old-plan")
|
||||
})
|
||||
|
||||
test("should strip ultrawork/ulw keywords from plan name argument", async () => {
|
||||
// #given - plan with ultrawork keyword in user-request
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "my-feature-plan.md")
|
||||
writeFileSync(planPath, "# My Feature Plan\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
my-feature-plan ultrawork
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when - user specifies plan with ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan without ultrawork suffix
|
||||
expect(output.parts[0].text).toContain("my-feature-plan")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should strip ulw keyword from plan name argument", async () => {
|
||||
// #given - plan with ulw keyword in user-request
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "api-refactor.md")
|
||||
writeFileSync(planPath, "# API Refactor\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
api-refactor ulw
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan without ulw suffix
|
||||
expect(output.parts[0].text).toContain("api-refactor")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
|
||||
test("should match plan by partial name", async () => {
|
||||
// #given - user specifies partial plan name
|
||||
const plansDir = join(TEST_DIR, ".sisyphus", "plans")
|
||||
mkdirSync(plansDir, { recursive: true })
|
||||
|
||||
const planPath = join(plansDir, "2026-01-15-feature-implementation.md")
|
||||
writeFileSync(planPath, "# Feature Implementation\n- [ ] Task 1")
|
||||
|
||||
const hook = createStartWorkHook(createMockPluginInput())
|
||||
const output = {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Start Sisyphus work session
|
||||
<user-request>
|
||||
feature-implementation
|
||||
</user-request>`,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "session-123" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - should find plan by partial match
|
||||
expect(output.parts[0].text).toContain("2026-01-15-feature-implementation")
|
||||
expect(output.parts[0].text).toContain("Auto-Selected Plan")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,11 +7,14 @@ import {
|
||||
getPlanProgress,
|
||||
createBoulderState,
|
||||
getPlanName,
|
||||
clearBoulderState,
|
||||
} from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export const HOOK_NAME = "start-work"
|
||||
|
||||
const KEYWORD_PATTERN = /\b(ultrawork|ulw)\b/gi
|
||||
|
||||
interface StartWorkHookInput {
|
||||
sessionID: string
|
||||
messageID?: string
|
||||
@@ -21,6 +24,27 @@ interface StartWorkHookOutput {
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
}
|
||||
|
||||
function extractUserRequestPlanName(promptText: string): string | null {
|
||||
const userRequestMatch = promptText.match(/<user-request>\s*([\s\S]*?)\s*<\/user-request>/i)
|
||||
if (!userRequestMatch) return null
|
||||
|
||||
const rawArg = userRequestMatch[1].trim()
|
||||
if (!rawArg) return null
|
||||
|
||||
const cleanedArg = rawArg.replace(KEYWORD_PATTERN, "").trim()
|
||||
return cleanedArg || null
|
||||
}
|
||||
|
||||
function findPlanByName(plans: string[], requestedName: string): string | null {
|
||||
const lowerName = requestedName.toLowerCase()
|
||||
|
||||
const exactMatch = plans.find(p => getPlanName(p).toLowerCase() === lowerName)
|
||||
if (exactMatch) return exactMatch
|
||||
|
||||
const partialMatch = plans.find(p => getPlanName(p).toLowerCase().includes(lowerName))
|
||||
return partialMatch || null
|
||||
}
|
||||
|
||||
export function createStartWorkHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -51,8 +75,70 @@ export function createStartWorkHook(ctx: PluginInput) {
|
||||
const timestamp = new Date().toISOString()
|
||||
|
||||
let contextInfo = ""
|
||||
|
||||
const explicitPlanName = extractUserRequestPlanName(promptText)
|
||||
|
||||
if (explicitPlanName) {
|
||||
log(`[${HOOK_NAME}] Explicit plan name requested: ${explicitPlanName}`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
|
||||
const allPlans = findPrometheusPlans(ctx.directory)
|
||||
const matchedPlan = findPlanByName(allPlans, explicitPlanName)
|
||||
|
||||
if (matchedPlan) {
|
||||
const progress = getPlanProgress(matchedPlan)
|
||||
|
||||
if (progress.isComplete) {
|
||||
contextInfo = `
|
||||
## Plan Already Complete
|
||||
|
||||
if (existingState) {
|
||||
The requested plan "${getPlanName(matchedPlan)}" has been completed.
|
||||
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
|
||||
} else {
|
||||
if (existingState) {
|
||||
clearBoulderState(ctx.directory)
|
||||
}
|
||||
const newState = createBoulderState(matchedPlan, sessionId)
|
||||
writeBoulderState(ctx.directory, newState)
|
||||
|
||||
contextInfo = `
|
||||
## Auto-Selected Plan
|
||||
|
||||
**Plan**: ${getPlanName(matchedPlan)}
|
||||
**Path**: ${matchedPlan}
|
||||
**Progress**: ${progress.completed}/${progress.total} tasks
|
||||
**Session ID**: ${sessionId}
|
||||
**Started**: ${timestamp}
|
||||
|
||||
boulder.json has been created. Read the plan and begin execution.`
|
||||
}
|
||||
} else {
|
||||
const incompletePlans = allPlans.filter(p => !getPlanProgress(p).isComplete)
|
||||
if (incompletePlans.length > 0) {
|
||||
const planList = incompletePlans.map((p, i) => {
|
||||
const prog = getPlanProgress(p)
|
||||
return `${i + 1}. [${getPlanName(p)}] - Progress: ${prog.completed}/${prog.total}`
|
||||
}).join("\n")
|
||||
|
||||
contextInfo = `
|
||||
## Plan Not Found
|
||||
|
||||
Could not find a plan matching "${explicitPlanName}".
|
||||
|
||||
Available incomplete plans:
|
||||
${planList}
|
||||
|
||||
Ask the user which plan to work on.`
|
||||
} else {
|
||||
contextInfo = `
|
||||
## Plan Not Found
|
||||
|
||||
Could not find a plan matching "${explicitPlanName}".
|
||||
No incomplete plans available. Create a new plan with: /plan "your task"`
|
||||
}
|
||||
}
|
||||
} else if (existingState) {
|
||||
const progress = getPlanProgress(existingState.active_plan)
|
||||
|
||||
if (!progress.isComplete) {
|
||||
@@ -78,7 +164,7 @@ Looking for new plans...`
|
||||
}
|
||||
}
|
||||
|
||||
if (!existingState || getPlanProgress(existingState.active_plan).isComplete) {
|
||||
if ((!existingState && !explicitPlanName) || (existingState && !explicitPlanName && getPlanProgress(existingState.active_plan).isComplete)) {
|
||||
const plans = findPrometheusPlans(ctx.directory)
|
||||
const incompletePlans = plans.filter(p => !getPlanProgress(p).isComplete)
|
||||
|
||||
|
||||
@@ -807,4 +807,26 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #then - no continuation (API fallback detected the abort)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should pass model property in prompt call (undefined when no message context)", async () => {
|
||||
// #given - session with incomplete todos, no prior message context available
|
||||
const sessionID = "main-model-preserve"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
})
|
||||
|
||||
// #when - session goes idle and continuation is injected
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - prompt call made, model is undefined when no context (expected behavior)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
expect("model" in promptCalls[0]).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getMainSessionID, subagentSessions } from "../features/claude-code-sess
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
type ToolPermission,
|
||||
} from "../features/hook-message-injector"
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
@@ -151,7 +152,18 @@ export function createTodoContinuationEnforcer(
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
|
||||
interface ResolvedMessageInfo {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
|
||||
async function injectContinuation(
|
||||
sessionID: string,
|
||||
incompleteCount: number,
|
||||
total: number,
|
||||
resolvedInfo?: ResolvedMessageInfo
|
||||
): Promise<void> {
|
||||
const state = sessions.get(sessionID)
|
||||
|
||||
if (state?.isRecovering) {
|
||||
@@ -159,8 +171,6 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -185,35 +195,45 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
let agentName = resolvedInfo?.agent
|
||||
let model = resolvedInfo?.model
|
||||
let tools = resolvedInfo?.tools
|
||||
|
||||
if (!agentName || !model) {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
agentName = agentName ?? prevMessage?.agent
|
||||
model = model ?? (prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined)
|
||||
tools = tools ?? prevMessage?.tools
|
||||
}
|
||||
|
||||
const agentName = prevMessage?.agent
|
||||
if (agentName && skipAgents.includes(agentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||
return
|
||||
}
|
||||
|
||||
const editPermission = prevMessage?.tools?.edit
|
||||
const writePermission = prevMessage?.tools?.write
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
const editPermission = tools?.edit
|
||||
const writePermission = tools?.write
|
||||
const hasWritePermission = !tools ||
|
||||
((editPermission !== false && editPermission !== "deny") &&
|
||||
(writePermission !== false && writePermission !== "deny"))
|
||||
if (!hasWritePermission) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
|
||||
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: agentName })
|
||||
return
|
||||
}
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: agentName, model, incompleteCount: freshIncompleteCount })
|
||||
|
||||
// Don't pass model - let OpenCode use session's existing lastModel
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
agent: agentName,
|
||||
...(model !== undefined ? { model } : {}),
|
||||
parts: [{ type: "text", text: prompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
@@ -225,7 +245,12 @@ export function createTodoContinuationEnforcer(
|
||||
}
|
||||
}
|
||||
|
||||
function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
|
||||
function startCountdown(
|
||||
sessionID: string,
|
||||
incompleteCount: number,
|
||||
total: number,
|
||||
resolvedInfo?: ResolvedMessageInfo
|
||||
): void {
|
||||
const state = getState(sessionID)
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
@@ -242,7 +267,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
state.countdownTimer = setTimeout(() => {
|
||||
cancelCountdown(sessionID)
|
||||
injectContinuation(sessionID, incompleteCount, total)
|
||||
injectContinuation(sessionID, incompleteCount, total, resolvedInfo)
|
||||
}, COUNTDOWN_SECONDS * 1000)
|
||||
|
||||
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
|
||||
@@ -346,15 +371,26 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
let agentName: string | undefined
|
||||
let resolvedInfo: ResolvedMessageInfo | undefined
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
const messages = (messagesResp.data ?? []) as Array<{ info?: { agent?: string } }>
|
||||
const messages = (messagesResp.data ?? []) as Array<{
|
||||
info?: {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
tools?: Record<string, ToolPermission>
|
||||
}
|
||||
}>
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info?.agent) {
|
||||
agentName = messages[i].info?.agent
|
||||
const info = messages[i].info
|
||||
if (info?.agent || info?.model) {
|
||||
resolvedInfo = {
|
||||
agent: info.agent,
|
||||
model: info.model,
|
||||
tools: info.tools,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -362,13 +398,13 @@ export function createTodoContinuationEnforcer(
|
||||
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName, skipAgents })
|
||||
if (agentName && skipAgents.includes(agentName)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: agentName })
|
||||
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents })
|
||||
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
|
||||
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
|
||||
return
|
||||
}
|
||||
|
||||
startCountdown(sessionID, incompleteCount, todos.length)
|
||||
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,6 @@ const TRUNCATABLE_TOOLS = [
|
||||
"glob",
|
||||
"Glob",
|
||||
"safe_glob",
|
||||
"lsp_find_references",
|
||||
"lsp_document_symbols",
|
||||
"lsp_workspace_symbols",
|
||||
"lsp_diagnostics",
|
||||
"ast_grep_search",
|
||||
"interactive_bash",
|
||||
|
||||
13
src/index.ts
13
src/index.ts
@@ -26,6 +26,7 @@ import {
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
createEditErrorRecoveryHook,
|
||||
createSisyphusTaskRetryHook,
|
||||
createTaskResumeInfoHook,
|
||||
createStartWorkHook,
|
||||
createSisyphusOrchestratorHook,
|
||||
@@ -72,7 +73,7 @@ import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { initTaskToastManager } from "./features/task-toast-manager";
|
||||
import { type HookName } from "./config";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning } from "./shared";
|
||||
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
@@ -201,6 +202,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
|
||||
const sisyphusTaskRetry = isHookEnabled("sisyphus-task-retry")
|
||||
? createSisyphusTaskRetryHook(ctx)
|
||||
: null;
|
||||
|
||||
const startWork = isHookEnabled("start-work")
|
||||
? createStartWorkHook(ctx)
|
||||
: null;
|
||||
@@ -440,6 +445,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
}
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionAgent(sessionInfo.id);
|
||||
resetMessageCursor(sessionInfo.id);
|
||||
firstMessageVariantGate.clear(sessionInfo.id);
|
||||
await skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
await lspManager.cleanupTempDirectoryClients();
|
||||
@@ -548,8 +554,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
||||
await agentUsageReminder?.["tool.execute.after"](input, output);
|
||||
await interactiveBashSession?.["tool.execute.after"](input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
await sisyphusTaskRetry?.["tool.execute.after"](input, output);
|
||||
await sisyphusOrchestrator?.["tool.execute.after"]?.(input, output);
|
||||
await taskResumeInfo["tool.execute.after"](input, output);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -154,7 +154,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
};
|
||||
|
||||
agentConfig["Sisyphus-Junior"] = createSisyphusJuniorAgentWithOverrides(
|
||||
pluginConfig.agents?.["Sisyphus-Junior"]
|
||||
pluginConfig.agents?.["Sisyphus-Junior"],
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
if (builderEnabled) {
|
||||
@@ -253,7 +254,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan
|
||||
? { mode: "subagent" as const, hidden: true }
|
||||
? { mode: "subagent" as const }
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
@@ -305,6 +306,12 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
if (agentResult["Prometheus (Planner)"]) {
|
||||
(agentResult["Prometheus (Planner)"] as { tools?: Record<string, unknown> }).tools = {
|
||||
...(agentResult["Prometheus (Planner)"] as { tools?: Record<string, unknown> }).tools,
|
||||
call_omo_agent: false,
|
||||
};
|
||||
}
|
||||
|
||||
config.permission = {
|
||||
...(config.permission as Record<string, unknown>),
|
||||
|
||||
@@ -7,6 +7,7 @@ Cross-cutting utilities for path resolution, config management, text processing,
|
||||
```
|
||||
shared/
|
||||
├── index.ts # Barrel export
|
||||
├── agent-variant.ts # Agent model/prompt variation logic
|
||||
├── claude-config-dir.ts # ~/.claude resolution
|
||||
├── command-executor.ts # Shell exec with variable expansion
|
||||
├── config-errors.ts # Global error tracking
|
||||
@@ -14,23 +15,31 @@ shared/
|
||||
├── data-path.ts # XDG data directory
|
||||
├── deep-merge.ts # Type-safe recursive merge
|
||||
├── dynamic-truncator.ts # Token-aware truncation
|
||||
├── external-plugin-detector.ts # Detect marketplace plugins
|
||||
├── file-reference-resolver.ts # @filename syntax
|
||||
├── file-utils.ts # Symlink, markdown detection
|
||||
├── first-message-variant.ts # Initial prompt variations
|
||||
├── frontmatter.ts # YAML frontmatter parsing
|
||||
├── hook-disabled.ts # Check if hook disabled
|
||||
├── jsonc-parser.ts # JSON with Comments
|
||||
├── logger.ts # File-based logging
|
||||
├── migration.ts # Legacy name compat (omo → Sisyphus)
|
||||
├── model-sanitizer.ts # Normalize model names
|
||||
├── opencode-config-dir.ts # ~/.config/opencode resolution
|
||||
├── opencode-version.ts # Version comparison logic
|
||||
├── pattern-matcher.ts # Tool name matching
|
||||
├── permission-compat.ts # Legacy permission mapping
|
||||
├── session-cursor.ts # Track message history pointer
|
||||
├── snake-case.ts # Case conversion
|
||||
└── tool-name.ts # PascalCase normalization
|
||||
├── tool-name.ts # PascalCase normalization
|
||||
└── zip-extractor.ts # Plugin installation utility
|
||||
```
|
||||
|
||||
## WHEN TO USE
|
||||
| Task | Utility |
|
||||
|------|---------|
|
||||
| Find ~/.claude | `getClaudeConfigDir()` |
|
||||
| Find ~/.config/opencode | `getOpenCodeConfigDir()` |
|
||||
| Merge configs | `deepMerge(base, override)` |
|
||||
| Parse user files | `parseJsonc()` |
|
||||
| Check hook enabled | `isHookDisabled(name, list)` |
|
||||
@@ -38,6 +47,9 @@ shared/
|
||||
| Resolve @file | `resolveFileReferencesInText()` |
|
||||
| Execute shell | `resolveCommandsInText()` |
|
||||
| Legacy names | `migrateLegacyAgentNames()` |
|
||||
| Version check | `isOpenCodeVersionAtLeast(version)` |
|
||||
| Map permissions | `normalizePermission()` |
|
||||
| Track session | `SessionCursor` |
|
||||
|
||||
## CRITICAL PATTERNS
|
||||
```typescript
|
||||
@@ -49,10 +61,14 @@ const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
|
||||
|
||||
// Safe JSONC parsing for user-edited files
|
||||
const { config, error } = parseJsoncSafe(content)
|
||||
|
||||
// Version-gated features
|
||||
if (isOpenCodeVersionAtLeast('1.0.150')) { /* ... */ }
|
||||
```
|
||||
|
||||
## ANTI-PATTERNS
|
||||
- Hardcoding paths (use `getClaudeConfigDir`, `getUserConfigPath`)
|
||||
- Hardcoding paths (use `getClaudeConfigDir`, `getOpenCodeConfigDir`)
|
||||
- Using `JSON.parse` for user configs (always use `parseJsonc`)
|
||||
- Ignoring output size (large tool outputs MUST use `dynamicTruncate`)
|
||||
- Manual case conversion (use `toSnakeCase`, `normalizeToolName`)
|
||||
- Manual version parsing (use `opencode-version.ts` utilities)
|
||||
- Raw permission checks (use `permission-compat.ts`)
|
||||
|
||||
@@ -22,3 +22,5 @@ export * from "./permission-compat"
|
||||
export * from "./external-plugin-detector"
|
||||
export * from "./zip-extractor"
|
||||
export * from "./agent-variant"
|
||||
export * from "./session-cursor"
|
||||
export * from "./shell-env"
|
||||
|
||||
@@ -55,6 +55,7 @@ describe("migrateAgentNames", () => {
|
||||
const agents = {
|
||||
SISYPHUS: { model: "test" },
|
||||
"planner-sisyphus": { prompt: "test" },
|
||||
"Orchestrator-Sisyphus": { model: "openai/gpt-5.2" },
|
||||
}
|
||||
|
||||
// #when: Migrate agent names
|
||||
@@ -63,6 +64,7 @@ describe("migrateAgentNames", () => {
|
||||
// #then: Case-insensitive lookup should migrate correctly
|
||||
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
|
||||
expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "test" })
|
||||
expect(migrated["orchestrator-sisyphus"]).toEqual({ model: "openai/gpt-5.2" })
|
||||
})
|
||||
|
||||
test("passes through unknown agent names unchanged", () => {
|
||||
@@ -457,13 +459,13 @@ describe("migrateConfigFile with backup", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("creates backup file with timestamp when migration needed", () => {
|
||||
// #given: Config file path and config needing migration
|
||||
test("creates backup file with timestamp when legacy migration needed", () => {
|
||||
// #given: Config file path with legacy agent names needing migration
|
||||
const testConfigPath = "/tmp/test-config-migration.json"
|
||||
const testConfigContent = globalThis.JSON.stringify({ agents: { oracle: { model: "openai/gpt-5.2" } } }, null, 2)
|
||||
const testConfigContent = globalThis.JSON.stringify({ agents: { omo: { model: "test" } } }, null, 2)
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
omo: { model: "test" },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -492,70 +494,54 @@ describe("migrateConfigFile with backup", () => {
|
||||
expect(backupContent).toBe(testConfigContent)
|
||||
})
|
||||
|
||||
test("deletes agent config when all fields match category defaults", () => {
|
||||
// #given: Config with agent matching category defaults
|
||||
const testConfigPath = "/tmp/test-config-delete.json"
|
||||
test("preserves model setting without auto-conversion to category", () => {
|
||||
// #given: Config with model setting (should NOT be converted to category)
|
||||
const testConfigPath = "/tmp/test-config-preserve-model.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.1,
|
||||
},
|
||||
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
"my-custom-agent": { model: "google/gemini-3-pro-preview" },
|
||||
},
|
||||
}
|
||||
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { oracle: { model: "openai/gpt-5.2" } } }, null, 2))
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Agent should be deleted (matches strategic category defaults)
|
||||
expect(needsWrite).toBe(true)
|
||||
// #then: No migration needed - model settings should be preserved as-is
|
||||
expect(needsWrite).toBe(false)
|
||||
|
||||
const migratedConfig = JSON.parse(fs.readFileSync(testConfigPath, "utf-8"))
|
||||
expect(migratedConfig.agents).toEqual({})
|
||||
|
||||
const dir = path.dirname(testConfigPath)
|
||||
const basename = path.basename(testConfigPath)
|
||||
const files = fs.readdirSync(dir)
|
||||
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
|
||||
backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f)))
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
|
||||
expect(agents.oracle.model).toBe("openai/gpt-5.2")
|
||||
expect(agents["my-custom-agent"].model).toBe("google/gemini-3-pro-preview")
|
||||
})
|
||||
|
||||
test("keeps agent config with category when fields differ from defaults", () => {
|
||||
// #given: Config with agent having custom temperature override
|
||||
const testConfigPath = "/tmp/test-config-keep.json"
|
||||
test("preserves category setting when explicitly set", () => {
|
||||
// #given: Config with explicit category setting
|
||||
const testConfigPath = "/tmp/test-config-preserve-category.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: {
|
||||
model: "openai/gpt-5.2",
|
||||
temperature: 0.5,
|
||||
},
|
||||
"multimodal-looker": { category: "quick" },
|
||||
oracle: { category: "ultrabrain" },
|
||||
},
|
||||
}
|
||||
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { oracle: { model: "openai/gpt-5.2" } } }, null, 2))
|
||||
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify(rawConfig, null, 2))
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Agent should be kept with category and custom override
|
||||
expect(needsWrite).toBe(true)
|
||||
// #then: No migration needed - category settings should be preserved as-is
|
||||
expect(needsWrite).toBe(false)
|
||||
|
||||
const migratedConfig = JSON.parse(fs.readFileSync(testConfigPath, "utf-8"))
|
||||
const agents = migratedConfig.agents as Record<string, unknown>
|
||||
expect(agents.oracle).toBeDefined()
|
||||
expect((agents.oracle as Record<string, unknown>).category).toBe("ultrabrain")
|
||||
expect((agents.oracle as Record<string, unknown>).temperature).toBe(0.5)
|
||||
expect((agents.oracle as Record<string, unknown>).model).toBeUndefined()
|
||||
|
||||
const dir = path.dirname(testConfigPath)
|
||||
const basename = path.basename(testConfigPath)
|
||||
const files = fs.readdirSync(dir)
|
||||
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
|
||||
backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f)))
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
expect(agents["multimodal-looker"].category).toBe("quick")
|
||||
expect(agents.oracle.category).toBe("ultrabrain")
|
||||
})
|
||||
|
||||
test("does not write when no migration needed", () => {
|
||||
@@ -583,56 +569,5 @@ describe("migrateConfigFile with backup", () => {
|
||||
expect(backupFiles.length).toBe(0)
|
||||
})
|
||||
|
||||
test("handles multiple agent migrations correctly", () => {
|
||||
// #given: Config with multiple agents needing migration
|
||||
const testConfigPath = "/tmp/test-config-multi-agent.json"
|
||||
const rawConfig: Record<string, unknown> = {
|
||||
agents: {
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "anthropic/claude-sonnet-4-5" },
|
||||
frontend: {
|
||||
model: "google/gemini-3-pro-preview",
|
||||
temperature: 0.9,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
testConfigPath,
|
||||
globalThis.JSON.stringify(
|
||||
{
|
||||
agents: {
|
||||
oracle: { model: "openai/gpt-5.2" },
|
||||
librarian: { model: "anthropic/claude-sonnet-4-5" },
|
||||
frontend: { model: "google/gemini-3-pro-preview" },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
)
|
||||
cleanupPaths.push(testConfigPath)
|
||||
|
||||
// #when: Migrate config file
|
||||
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
|
||||
|
||||
// #then: Should migrate correctly
|
||||
expect(needsWrite).toBe(true)
|
||||
|
||||
const migratedConfig = JSON.parse(fs.readFileSync(testConfigPath, "utf-8"))
|
||||
const agents = migratedConfig.agents as Record<string, unknown>
|
||||
|
||||
expect(agents.oracle).toBeUndefined()
|
||||
expect(agents.librarian).toBeUndefined()
|
||||
|
||||
expect(agents.frontend).toBeDefined()
|
||||
expect((agents.frontend as Record<string, unknown>).category).toBe("visual-engineering")
|
||||
expect((agents.frontend as Record<string, unknown>).temperature).toBe(0.9)
|
||||
|
||||
const dir = path.dirname(testConfigPath)
|
||||
const basename = path.basename(testConfigPath)
|
||||
const files = fs.readdirSync(dir)
|
||||
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
|
||||
backupFiles.forEach((f) => cleanupPaths.push(path.join(dir, f)))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,8 +20,24 @@ export const AGENT_NAME_MAP: Record<string, string> = {
|
||||
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
|
||||
"document-writer": "document-writer",
|
||||
"multimodal-looker": "multimodal-looker",
|
||||
"orchestrator-sisyphus": "orchestrator-sisyphus",
|
||||
}
|
||||
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"Sisyphus",
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer",
|
||||
"multimodal-looker",
|
||||
"Metis (Plan Consultant)",
|
||||
"Momus (Plan Reviewer)",
|
||||
"Prometheus (Planner)",
|
||||
"orchestrator-sisyphus",
|
||||
"build",
|
||||
])
|
||||
|
||||
// Migration map: old hook names → new hook names (for backward compatibility)
|
||||
export const HOOK_NAME_MAP: Record<string, string> = {
|
||||
// Legacy names (backward compatibility)
|
||||
@@ -117,21 +133,7 @@ export function migrateConfigFile(configPath: string, rawConfig: Record<string,
|
||||
}
|
||||
}
|
||||
|
||||
if (rawConfig.agents && typeof rawConfig.agents === "object") {
|
||||
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
|
||||
for (const [name, config] of Object.entries(agents)) {
|
||||
const { migrated, changed } = migrateAgentConfigToCategory(config)
|
||||
if (changed) {
|
||||
const category = migrated.category as string
|
||||
if (shouldDeleteAgentConfig(migrated, category)) {
|
||||
delete agents[name]
|
||||
} else {
|
||||
agents[name] = migrated
|
||||
}
|
||||
needsWrite = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (rawConfig.omo_agent) {
|
||||
rawConfig.sisyphus_agent = rawConfig.omo_agent
|
||||
|
||||
66
src/shared/session-cursor.test.ts
Normal file
66
src/shared/session-cursor.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it } from "bun:test"
|
||||
import { consumeNewMessages, resetMessageCursor } from "./session-cursor"
|
||||
|
||||
describe("consumeNewMessages", () => {
|
||||
const sessionID = "session-123"
|
||||
|
||||
const buildMessage = (id: string, created: number) => ({
|
||||
info: { id, time: { created } },
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resetMessageCursor(sessionID)
|
||||
})
|
||||
|
||||
it("returns all messages on first read and none on repeat", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
|
||||
// #when
|
||||
const first = consumeNewMessages(sessionID, messages)
|
||||
const second = consumeNewMessages(sessionID, messages)
|
||||
|
||||
// #then
|
||||
expect(first).toEqual(messages)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it("returns only new messages after cursor advances", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const extended = [...messages, buildMessage("m3", 3)]
|
||||
|
||||
// #when
|
||||
const next = consumeNewMessages(sessionID, extended)
|
||||
|
||||
// #then
|
||||
expect(next).toEqual([extended[2]])
|
||||
})
|
||||
|
||||
it("resets when message history shrinks", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const shorter = [buildMessage("n1", 1)]
|
||||
|
||||
// #when
|
||||
const next = consumeNewMessages(sessionID, shorter)
|
||||
|
||||
// #then
|
||||
expect(next).toEqual(shorter)
|
||||
})
|
||||
|
||||
it("returns all messages when last key is missing", () => {
|
||||
// #given
|
||||
const messages = [buildMessage("m1", 1), buildMessage("m2", 2)]
|
||||
consumeNewMessages(sessionID, messages)
|
||||
const replaced = [buildMessage("n1", 1), buildMessage("n2", 2)]
|
||||
|
||||
// #when
|
||||
const next = consumeNewMessages(sessionID, replaced)
|
||||
|
||||
// #then
|
||||
expect(next).toEqual(replaced)
|
||||
})
|
||||
})
|
||||
85
src/shared/session-cursor.ts
Normal file
85
src/shared/session-cursor.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
type MessageTime =
|
||||
| { created?: number | string }
|
||||
| number
|
||||
| string
|
||||
| undefined
|
||||
|
||||
type MessageInfo = {
|
||||
id?: string
|
||||
time?: MessageTime
|
||||
}
|
||||
|
||||
export type CursorMessage = {
|
||||
info?: MessageInfo
|
||||
}
|
||||
|
||||
interface CursorState {
|
||||
lastKey?: string
|
||||
lastCount: number
|
||||
}
|
||||
|
||||
const sessionCursors = new Map<string, CursorState>()
|
||||
|
||||
function buildMessageKey(message: CursorMessage, index: number): string {
|
||||
const id = message.info?.id
|
||||
if (id) return `id:${id}`
|
||||
|
||||
const time = message.info?.time
|
||||
if (typeof time === "number" || typeof time === "string") {
|
||||
return `t:${time}:${index}`
|
||||
}
|
||||
|
||||
const created = time?.created
|
||||
if (typeof created === "number") {
|
||||
return `t:${created}:${index}`
|
||||
}
|
||||
if (typeof created === "string") {
|
||||
return `t:${created}:${index}`
|
||||
}
|
||||
|
||||
return `i:${index}`
|
||||
}
|
||||
|
||||
export function consumeNewMessages<T extends CursorMessage>(
|
||||
sessionID: string | undefined,
|
||||
messages: T[]
|
||||
): T[] {
|
||||
if (!sessionID) return messages
|
||||
|
||||
const keys = messages.map((message, index) => buildMessageKey(message, index))
|
||||
const cursor = sessionCursors.get(sessionID)
|
||||
let startIndex = 0
|
||||
|
||||
if (cursor) {
|
||||
if (cursor.lastCount > messages.length) {
|
||||
startIndex = 0
|
||||
} else if (cursor.lastKey) {
|
||||
const lastIndex = keys.lastIndexOf(cursor.lastKey)
|
||||
if (lastIndex >= 0) {
|
||||
startIndex = lastIndex + 1
|
||||
} else {
|
||||
// History changed without a shrink; reset to avoid skipping messages.
|
||||
startIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (messages.length === 0) {
|
||||
sessionCursors.delete(sessionID)
|
||||
} else {
|
||||
sessionCursors.set(sessionID, {
|
||||
lastKey: keys[keys.length - 1],
|
||||
lastCount: messages.length,
|
||||
})
|
||||
}
|
||||
|
||||
return messages.slice(startIndex)
|
||||
}
|
||||
|
||||
export function resetMessageCursor(sessionID?: string): void {
|
||||
if (sessionID) {
|
||||
sessionCursors.delete(sessionID)
|
||||
return
|
||||
}
|
||||
sessionCursors.clear()
|
||||
}
|
||||
278
src/shared/shell-env.test.ts
Normal file
278
src/shared/shell-env.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { detectShellType, shellEscape, buildEnvPrefix } from "./shell-env"
|
||||
|
||||
describe("shell-env", () => {
|
||||
let originalPlatform: NodeJS.Platform
|
||||
let originalEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
originalEnv = {
|
||||
SHELL: process.env.SHELL,
|
||||
PSModulePath: process.env.PSModulePath,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
} else {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("detectShellType", () => {
|
||||
test("#given SHELL env var set to /bin/bash #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given SHELL env var set to /bin/zsh #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
process.env.SHELL = "/bin/zsh"
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given PSModulePath is set #when detectShellType is called #then returns powershell", () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("powershell")
|
||||
})
|
||||
|
||||
test("#given Windows platform without PSModulePath #when detectShellType is called #then returns cmd", () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("cmd")
|
||||
})
|
||||
|
||||
test("#given non-Windows platform without SHELL env var #when detectShellType is called #then returns unix", () => {
|
||||
delete process.env.PSModulePath
|
||||
delete process.env.SHELL
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("unix")
|
||||
})
|
||||
|
||||
test("#given PSModulePath takes priority over SHELL #when both are set #then returns powershell", () => {
|
||||
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
|
||||
const result = detectShellType()
|
||||
|
||||
expect(result).toBe("powershell")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shellEscape", () => {
|
||||
describe("unix shell", () => {
|
||||
test("#given plain alphanumeric string #when shellEscape is called with unix #then returns unquoted string", () => {
|
||||
const result = shellEscape("simple123", "unix")
|
||||
expect(result).toBe("simple123")
|
||||
})
|
||||
|
||||
test("#given empty string #when shellEscape is called with unix #then returns single quotes", () => {
|
||||
const result = shellEscape("", "unix")
|
||||
expect(result).toBe("''")
|
||||
})
|
||||
|
||||
test("#given string with spaces #when shellEscape is called with unix #then wraps in single quotes", () => {
|
||||
const result = shellEscape("has spaces", "unix")
|
||||
expect(result).toBe("'has spaces'")
|
||||
})
|
||||
|
||||
test("#given string with single quote #when shellEscape is called with unix #then escapes with backslash", () => {
|
||||
const result = shellEscape("it's", "unix")
|
||||
expect(result).toBe("'it'\\''s'")
|
||||
})
|
||||
|
||||
test("#given string with colon and slash #when shellEscape is called with unix #then returns unquoted", () => {
|
||||
const result = shellEscape("/usr/bin:/bin", "unix")
|
||||
expect(result).toBe("/usr/bin:/bin")
|
||||
})
|
||||
|
||||
test("#given string with newline #when shellEscape is called with unix #then preserves newline in quotes", () => {
|
||||
const result = shellEscape("line1\nline2", "unix")
|
||||
expect(result).toBe("'line1\nline2'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("powershell", () => {
|
||||
test("#given plain alphanumeric string #when shellEscape is called with powershell #then wraps in single quotes", () => {
|
||||
const result = shellEscape("simple123", "powershell")
|
||||
expect(result).toBe("'simple123'")
|
||||
})
|
||||
|
||||
test("#given empty string #when shellEscape is called with powershell #then returns single quotes", () => {
|
||||
const result = shellEscape("", "powershell")
|
||||
expect(result).toBe("''")
|
||||
})
|
||||
|
||||
test("#given string with spaces #when shellEscape is called with powershell #then wraps in single quotes", () => {
|
||||
const result = shellEscape("has spaces", "powershell")
|
||||
expect(result).toBe("'has spaces'")
|
||||
})
|
||||
|
||||
test("#given string with single quote #when shellEscape is called with powershell #then escapes with double quote", () => {
|
||||
const result = shellEscape("it's", "powershell")
|
||||
expect(result).toBe("'it''s'")
|
||||
})
|
||||
|
||||
test("#given string with dollar sign #when shellEscape is called with powershell #then wraps to prevent expansion", () => {
|
||||
const result = shellEscape("$var", "powershell")
|
||||
expect(result).toBe("'$var'")
|
||||
})
|
||||
|
||||
test("#given Windows path with backslashes #when shellEscape is called with powershell #then preserves backslashes", () => {
|
||||
const result = shellEscape("C:\\path", "powershell")
|
||||
expect(result).toBe("'C:\\path'")
|
||||
})
|
||||
|
||||
test("#given string with colon #when shellEscape is called with powershell #then wraps in quotes", () => {
|
||||
const result = shellEscape("key:value", "powershell")
|
||||
expect(result).toBe("'key:value'")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cmd.exe", () => {
|
||||
test("#given plain alphanumeric string #when shellEscape is called with cmd #then wraps in double quotes", () => {
|
||||
const result = shellEscape("simple123", "cmd")
|
||||
expect(result).toBe('"simple123"')
|
||||
})
|
||||
|
||||
test("#given empty string #when shellEscape is called with cmd #then returns double quotes", () => {
|
||||
const result = shellEscape("", "cmd")
|
||||
expect(result).toBe('""')
|
||||
})
|
||||
|
||||
test("#given string with spaces #when shellEscape is called with cmd #then wraps in double quotes", () => {
|
||||
const result = shellEscape("has spaces", "cmd")
|
||||
expect(result).toBe('"has spaces"')
|
||||
})
|
||||
|
||||
test("#given string with double quote #when shellEscape is called with cmd #then escapes with double quote", () => {
|
||||
const result = shellEscape('say "hello"', "cmd")
|
||||
expect(result).toBe('"say ""hello"""')
|
||||
})
|
||||
|
||||
test("#given string with percent signs #when shellEscape is called with cmd #then escapes percent signs", () => {
|
||||
const result = shellEscape("%PATH%", "cmd")
|
||||
expect(result).toBe('"%%PATH%%"')
|
||||
})
|
||||
|
||||
test("#given Windows path with backslashes #when shellEscape is called with cmd #then preserves backslashes", () => {
|
||||
const result = shellEscape("C:\\path", "cmd")
|
||||
expect(result).toBe('"C:\\path"')
|
||||
})
|
||||
|
||||
test("#given string with colon #when shellEscape is called with cmd #then wraps in double quotes", () => {
|
||||
const result = shellEscape("key:value", "cmd")
|
||||
expect(result).toBe('"key:value"')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildEnvPrefix", () => {
|
||||
describe("unix shell", () => {
|
||||
test("#given single environment variable #when buildEnvPrefix is called with unix #then builds export statement", () => {
|
||||
const result = buildEnvPrefix({ VAR: "value" }, "unix")
|
||||
expect(result).toBe("export VAR=value;")
|
||||
})
|
||||
|
||||
test("#given multiple environment variables #when buildEnvPrefix is called with unix #then builds export statement with all vars", () => {
|
||||
const result = buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "unix")
|
||||
expect(result).toBe("export VAR1=val1 VAR2=val2;")
|
||||
})
|
||||
|
||||
test("#given env var with special chars #when buildEnvPrefix is called with unix #then escapes value", () => {
|
||||
const result = buildEnvPrefix({ PATH: "/usr/bin:/bin" }, "unix")
|
||||
expect(result).toBe("export PATH=/usr/bin:/bin;")
|
||||
})
|
||||
|
||||
test("#given env var with spaces #when buildEnvPrefix is called with unix #then escapes with quotes", () => {
|
||||
const result = buildEnvPrefix({ MSG: "has spaces" }, "unix")
|
||||
expect(result).toBe("export MSG='has spaces';")
|
||||
})
|
||||
|
||||
test("#given empty env object #when buildEnvPrefix is called with unix #then returns empty string", () => {
|
||||
const result = buildEnvPrefix({}, "unix")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("powershell", () => {
|
||||
test("#given single environment variable #when buildEnvPrefix is called with powershell #then builds $env assignment", () => {
|
||||
const result = buildEnvPrefix({ VAR: "value" }, "powershell")
|
||||
expect(result).toBe("$env:VAR='value';")
|
||||
})
|
||||
|
||||
test("#given multiple environment variables #when buildEnvPrefix is called with powershell #then builds multiple assignments", () => {
|
||||
const result = buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "powershell")
|
||||
expect(result).toBe("$env:VAR1='val1'; $env:VAR2='val2';")
|
||||
})
|
||||
|
||||
test("#given env var with special chars #when buildEnvPrefix is called with powershell #then escapes value", () => {
|
||||
const result = buildEnvPrefix({ MSG: "it's working" }, "powershell")
|
||||
expect(result).toBe("$env:MSG='it''s working';")
|
||||
})
|
||||
|
||||
test("#given env var with dollar sign #when buildEnvPrefix is called with powershell #then escapes to prevent expansion", () => {
|
||||
const result = buildEnvPrefix({ VAR: "$test" }, "powershell")
|
||||
expect(result).toBe("$env:VAR='$test';")
|
||||
})
|
||||
|
||||
test("#given empty env object #when buildEnvPrefix is called with powershell #then returns empty string", () => {
|
||||
const result = buildEnvPrefix({}, "powershell")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
|
||||
describe("cmd.exe", () => {
|
||||
test("#given single environment variable #when buildEnvPrefix is called with cmd #then builds set command", () => {
|
||||
const result = buildEnvPrefix({ VAR: "value" }, "cmd")
|
||||
expect(result).toBe('set VAR="value" &&')
|
||||
})
|
||||
|
||||
test("#given multiple environment variables #when buildEnvPrefix is called with cmd #then builds multiple set commands", () => {
|
||||
const result = buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "cmd")
|
||||
expect(result).toBe('set VAR1="val1" && set VAR2="val2" &&')
|
||||
})
|
||||
|
||||
test("#given env var with special chars #when buildEnvPrefix is called with cmd #then escapes value", () => {
|
||||
const result = buildEnvPrefix({ MSG: "has spaces" }, "cmd")
|
||||
expect(result).toBe('set MSG="has spaces" &&')
|
||||
})
|
||||
|
||||
test("#given env var with double quotes #when buildEnvPrefix is called with cmd #then escapes quotes", () => {
|
||||
const result = buildEnvPrefix({ MSG: 'say "hello"' }, "cmd")
|
||||
expect(result).toBe('set MSG="say ""hello""" &&')
|
||||
})
|
||||
|
||||
test("#given empty env object #when buildEnvPrefix is called with cmd #then returns empty string", () => {
|
||||
const result = buildEnvPrefix({}, "cmd")
|
||||
expect(result).toBe("")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
111
src/shared/shell-env.ts
Normal file
111
src/shared/shell-env.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
export type ShellType = "unix" | "powershell" | "cmd"
|
||||
|
||||
/**
|
||||
* Detect the current shell type based on environment variables.
|
||||
*
|
||||
* Detection priority:
|
||||
* 1. PSModulePath → PowerShell
|
||||
* 2. SHELL env var → Unix shell
|
||||
* 3. Platform fallback → win32: cmd, others: unix
|
||||
*/
|
||||
export function detectShellType(): ShellType {
|
||||
if (process.env.PSModulePath) {
|
||||
return "powershell"
|
||||
}
|
||||
|
||||
if (process.env.SHELL) {
|
||||
return "unix"
|
||||
}
|
||||
|
||||
return process.platform === "win32" ? "cmd" : "unix"
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in environment variable assignment.
|
||||
*
|
||||
* @param value - The value to escape
|
||||
* @param shellType - The target shell type
|
||||
* @returns Escaped value appropriate for the shell
|
||||
*/
|
||||
export function shellEscape(value: string, shellType: ShellType): string {
|
||||
if (value === "") {
|
||||
return shellType === "cmd" ? '""' : "''"
|
||||
}
|
||||
|
||||
switch (shellType) {
|
||||
case "unix":
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
|
||||
case "powershell":
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
|
||||
case "cmd":
|
||||
// Escape % first (for environment variable expansion), then " (for quoting)
|
||||
return `"${value.replace(/%/g, '%%').replace(/"/g, '""')}"`
|
||||
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build environment variable prefix command for the target shell.
|
||||
*
|
||||
* @param env - Record of environment variables to set
|
||||
* @param shellType - The target shell type
|
||||
* @returns Command prefix string to prepend to the actual command
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Unix: "export VAR1=val1 VAR2=val2; command"
|
||||
* buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "unix")
|
||||
* // => "export VAR1=val1 VAR2=val2;"
|
||||
*
|
||||
* // PowerShell: "$env:VAR1='val1'; $env:VAR2='val2'; command"
|
||||
* buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "powershell")
|
||||
* // => "$env:VAR1='val1'; $env:VAR2='val2';"
|
||||
*
|
||||
* // cmd.exe: "set VAR1=val1 && set VAR2=val2 && command"
|
||||
* buildEnvPrefix({ VAR1: "val1", VAR2: "val2" }, "cmd")
|
||||
* // => "set VAR1=\"val1\" && set VAR2=\"val2\" &&"
|
||||
* ```
|
||||
*/
|
||||
export function buildEnvPrefix(
|
||||
env: Record<string, string>,
|
||||
shellType: ShellType
|
||||
): string {
|
||||
const entries = Object.entries(env)
|
||||
|
||||
if (entries.length === 0) {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch (shellType) {
|
||||
case "unix": {
|
||||
const assignments = entries
|
||||
.map(([key, value]) => `${key}=${shellEscape(value, shellType)}`)
|
||||
.join(" ")
|
||||
return `export ${assignments};`
|
||||
}
|
||||
|
||||
case "powershell": {
|
||||
const assignments = entries
|
||||
.map(([key, value]) => `$env:${key}=${shellEscape(value, shellType)}`)
|
||||
.join("; ")
|
||||
return `${assignments};`
|
||||
}
|
||||
|
||||
case "cmd": {
|
||||
const assignments = entries
|
||||
.map(([key, value]) => `set ${key}=${shellEscape(value, shellType)}`)
|
||||
.join(" && ")
|
||||
return `${assignments} &&`
|
||||
}
|
||||
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
# TOOLS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
Custom tools extending agent capabilities: LSP (11 tools), AST-aware search/replace, background tasks, and multimodal analysis.
|
||||
Custom tools extending agent capabilities: LSP (7 tools), AST-aware search/replace, background tasks, and multimodal analysis.
|
||||
|
||||
## STRUCTURE
|
||||
```
|
||||
@@ -20,17 +20,17 @@ tools/
|
||||
│ ├── tools.ts # Tool implementations
|
||||
│ └── config.ts, types.ts, utils.ts
|
||||
├── session-manager/ # OpenCode session history management
|
||||
├── sisyphus-task/ # Category-based delegation (583 lines)
|
||||
├── sisyphus-task/ # Category-based delegation (667 lines)
|
||||
├── skill/ # Skill loading/execution
|
||||
├── skill-mcp/ # Skill-embedded MCP invocation
|
||||
├── slashcommand/ # Slash command execution
|
||||
└── index.ts # builtinTools export (82 lines)
|
||||
└── index.ts # builtinTools export (75 lines)
|
||||
```
|
||||
|
||||
## TOOL CATEGORIES
|
||||
| Category | Tools | Purpose |
|
||||
|----------|-------|---------|
|
||||
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_diagnostics, lsp_rename, etc. | IDE-grade code intelligence (11 tools) |
|
||||
| LSP | lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_rename, etc. | IDE-grade code intelligence (7 tools) |
|
||||
| AST | ast_grep_search, ast_grep_replace | Structural pattern matching/rewriting |
|
||||
| Search | grep, glob | Timeout-safe file and content search |
|
||||
| Session | session_list, session_read, session_search, session_info | History navigation and retrieval |
|
||||
@@ -46,7 +46,7 @@ tools/
|
||||
## LSP SPECIFICS
|
||||
- **Lifecycle**: Lazy initialization on first call; auto-shutdown on idle.
|
||||
- **Config**: Merges `opencode.json` and `oh-my-opencode.json`.
|
||||
- **Capability**: Supports full LSP spec including `codeAction/resolve` and `prepareRename`.
|
||||
- **Capability**: Supports full LSP spec including `rename` and `prepareRename`.
|
||||
|
||||
## AST-GREP SPECIFICS
|
||||
- **Precision**: Uses tree-sitter for structural matching (avoids regex pitfalls).
|
||||
|
||||
@@ -7,6 +7,7 @@ import { BACKGROUND_TASK_DESCRIPTION, BACKGROUND_OUTPUT_DESCRIPTION, BACKGROUND_
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { consumeNewMessages } from "../../shared/session-cursor"
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -239,11 +240,26 @@ Session ID: ${task.sessionID}
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(task.sessionID, sortedMessages)
|
||||
if (newMessages.length === 0) {
|
||||
const duration = formatDuration(task.startedAt, task.completedAt)
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
Description: ${task.description}
|
||||
Duration: ${duration}
|
||||
Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No new output since last check)`
|
||||
}
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
for (const message of newMessages) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
|
||||
import type { CallOmoAgentArgs } from "./types"
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
import { log } from "../../shared/logger"
|
||||
import { consumeNewMessages } from "../../shared/session-cursor"
|
||||
import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
|
||||
@@ -290,11 +291,17 @@ async function executeSync(
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
const newMessages = consumeNewMessages(sessionID, sortedMessages)
|
||||
|
||||
if (newMessages.length === 0) {
|
||||
return `No new output since last check.\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
for (const message of newMessages) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const part of (message as any).parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import {
|
||||
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,
|
||||
lspManager,
|
||||
} from "./lsp"
|
||||
|
||||
@@ -60,17 +53,10 @@ export function createBackgroundTools(manager: BackgroundManager, client: Openco
|
||||
}
|
||||
|
||||
export const builtinTools: Record<string, ToolDefinition> = {
|
||||
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_grep_search,
|
||||
ast_grep_replace,
|
||||
grep,
|
||||
|
||||
@@ -64,7 +64,29 @@ export const interactive_bash: ToolDefinition = tool({
|
||||
|
||||
const subcommand = parts[0].toLowerCase()
|
||||
if (BLOCKED_TMUX_SUBCOMMANDS.includes(subcommand)) {
|
||||
return `Error: '${parts[0]}' is blocked. Use bash tool instead for capturing/printing terminal output.`
|
||||
const sessionIdx = parts.findIndex(p => p === "-t" || p.startsWith("-t"))
|
||||
let sessionName = "omo-session"
|
||||
if (sessionIdx !== -1) {
|
||||
if (parts[sessionIdx] === "-t" && parts[sessionIdx + 1]) {
|
||||
sessionName = parts[sessionIdx + 1]
|
||||
} else if (parts[sessionIdx].startsWith("-t")) {
|
||||
sessionName = parts[sessionIdx].slice(2)
|
||||
}
|
||||
}
|
||||
|
||||
return `Error: '${parts[0]}' is blocked in interactive_bash.
|
||||
|
||||
**USE BASH TOOL INSTEAD:**
|
||||
|
||||
\`\`\`bash
|
||||
# Capture terminal output
|
||||
tmux capture-pane -p -t ${sessionName}
|
||||
|
||||
# Or capture with history (last 1000 lines)
|
||||
tmux capture-pane -p -t ${sessionName} -S -1000
|
||||
\`\`\`
|
||||
|
||||
The Bash tool can execute these commands directly. Do NOT retry with interactive_bash.`
|
||||
}
|
||||
|
||||
const proc = Bun.spawn([tmuxPath, ...parts], {
|
||||
|
||||
73
src/tools/look-at/tools.test.ts
Normal file
73
src/tools/look-at/tools.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { normalizeArgs, validateArgs } from "./tools"
|
||||
|
||||
describe("look-at tool", () => {
|
||||
describe("normalizeArgs", () => {
|
||||
// #given LLM이 file_path 대신 path를 사용할 수 있음
|
||||
// #when path 파라미터로 호출
|
||||
// #then file_path로 정규화되어야 함
|
||||
test("normalizes path to file_path for LLM compatibility", () => {
|
||||
const args = { path: "/some/file.png", goal: "analyze" }
|
||||
const normalized = normalizeArgs(args as any)
|
||||
expect(normalized.file_path).toBe("/some/file.png")
|
||||
expect(normalized.goal).toBe("analyze")
|
||||
})
|
||||
|
||||
// #given 정상적인 file_path 사용
|
||||
// #when file_path 파라미터로 호출
|
||||
// #then 그대로 유지
|
||||
test("keeps file_path when properly provided", () => {
|
||||
const args = { file_path: "/correct/path.pdf", goal: "extract" }
|
||||
const normalized = normalizeArgs(args)
|
||||
expect(normalized.file_path).toBe("/correct/path.pdf")
|
||||
})
|
||||
|
||||
// #given 둘 다 제공된 경우
|
||||
// #when file_path와 path 모두 있음
|
||||
// #then file_path 우선
|
||||
test("prefers file_path over path when both provided", () => {
|
||||
const args = { file_path: "/preferred.png", path: "/fallback.png", goal: "test" }
|
||||
const normalized = normalizeArgs(args as any)
|
||||
expect(normalized.file_path).toBe("/preferred.png")
|
||||
})
|
||||
})
|
||||
|
||||
describe("validateArgs", () => {
|
||||
// #given 유효한 인자
|
||||
// #when 검증
|
||||
// #then null 반환 (에러 없음)
|
||||
test("returns null for valid args", () => {
|
||||
const args = { file_path: "/valid/path.png", goal: "analyze" }
|
||||
expect(validateArgs(args)).toBeNull()
|
||||
})
|
||||
|
||||
// #given file_path 누락
|
||||
// #when 검증
|
||||
// #then 명확한 에러 메시지
|
||||
test("returns error when file_path is missing", () => {
|
||||
const args = { goal: "analyze" } as any
|
||||
const error = validateArgs(args)
|
||||
expect(error).toContain("file_path")
|
||||
expect(error).toContain("required")
|
||||
})
|
||||
|
||||
// #given goal 누락
|
||||
// #when 검증
|
||||
// #then 명확한 에러 메시지
|
||||
test("returns error when goal is missing", () => {
|
||||
const args = { file_path: "/some/path.png" } as any
|
||||
const error = validateArgs(args)
|
||||
expect(error).toContain("goal")
|
||||
expect(error).toContain("required")
|
||||
})
|
||||
|
||||
// #given file_path가 빈 문자열
|
||||
// #when 검증
|
||||
// #then 에러 반환
|
||||
test("returns error when file_path is empty string", () => {
|
||||
const args = { file_path: "", goal: "analyze" }
|
||||
const error = validateArgs(args)
|
||||
expect(error).toContain("file_path")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -5,6 +5,27 @@ import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants"
|
||||
import type { LookAtArgs } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface LookAtArgsWithAlias extends LookAtArgs {
|
||||
path?: string
|
||||
}
|
||||
|
||||
export function normalizeArgs(args: LookAtArgsWithAlias): LookAtArgs {
|
||||
return {
|
||||
file_path: args.file_path ?? args.path ?? "",
|
||||
goal: args.goal ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
export function validateArgs(args: LookAtArgs): string | null {
|
||||
if (!args.file_path) {
|
||||
return `Error: Missing required parameter 'file_path'. Usage: look_at(file_path="/path/to/file", goal="what to extract")`
|
||||
}
|
||||
if (!args.goal) {
|
||||
return `Error: Missing required parameter 'goal'. Usage: look_at(file_path="/path/to/file", goal="what to extract")`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function inferMimeType(filePath: string): string {
|
||||
const ext = extname(filePath).toLowerCase()
|
||||
const mimeTypes: Record<string, string> = {
|
||||
@@ -50,7 +71,14 @@ export function createLookAt(ctx: PluginInput): ToolDefinition {
|
||||
file_path: tool.schema.string().describe("Absolute path to the file to analyze"),
|
||||
goal: tool.schema.string().describe("What specific information to extract from the file"),
|
||||
},
|
||||
async execute(args: LookAtArgs, toolContext) {
|
||||
async execute(rawArgs: LookAtArgs, toolContext) {
|
||||
const args = normalizeArgs(rawArgs as LookAtArgsWithAlias)
|
||||
const validationError = validateArgs(args)
|
||||
if (validationError) {
|
||||
log(`[look_at] Validation failed: ${validationError}`)
|
||||
return validationError
|
||||
}
|
||||
|
||||
log(`[look_at] Analyzing file: ${args.file_path}, goal: ${args.goal}`)
|
||||
|
||||
const mimeType = inferMimeType(args.file_path)
|
||||
|
||||
@@ -509,46 +509,6 @@ export class LSPClient {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
|
||||
async hover(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
})
|
||||
}
|
||||
|
||||
async definition(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
})
|
||||
}
|
||||
|
||||
async references(filePath: string, line: number, character: number, includeDeclaration = true): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
position: { line: line - 1, character },
|
||||
context: { includeDeclaration },
|
||||
})
|
||||
}
|
||||
|
||||
async documentSymbols(filePath: string): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/documentSymbol", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
})
|
||||
}
|
||||
|
||||
async workspaceSymbols(query: string): Promise<unknown> {
|
||||
return this.send("workspace/symbol", { query })
|
||||
}
|
||||
|
||||
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
|
||||
const absPath = resolve(filePath)
|
||||
const uri = pathToFileURL(absPath).href
|
||||
@@ -587,33 +547,6 @@ export class LSPClient {
|
||||
})
|
||||
}
|
||||
|
||||
async codeAction(
|
||||
filePath: string,
|
||||
startLine: number,
|
||||
startChar: number,
|
||||
endLine: number,
|
||||
endChar: number,
|
||||
only?: string[]
|
||||
): Promise<unknown> {
|
||||
const absPath = resolve(filePath)
|
||||
await this.openFile(absPath)
|
||||
return this.send("textDocument/codeAction", {
|
||||
textDocument: { uri: pathToFileURL(absPath).href },
|
||||
range: {
|
||||
start: { line: startLine - 1, character: startChar },
|
||||
end: { line: endLine - 1, character: endChar },
|
||||
},
|
||||
context: {
|
||||
diagnostics: [],
|
||||
only,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async codeActionResolve(codeAction: unknown): Promise<unknown> {
|
||||
return this.send("codeAction/resolve", codeAction)
|
||||
}
|
||||
|
||||
isAlive(): boolean {
|
||||
return this.proc !== null && !this.processExited && this.proc.exitCode === null
|
||||
}
|
||||
|
||||
@@ -1,34 +1,5 @@
|
||||
import type { LSPServerConfig } from "./types"
|
||||
|
||||
export const SYMBOL_KIND_MAP: Record<number, string> = {
|
||||
1: "File",
|
||||
2: "Module",
|
||||
3: "Namespace",
|
||||
4: "Package",
|
||||
5: "Class",
|
||||
6: "Method",
|
||||
7: "Property",
|
||||
8: "Field",
|
||||
9: "Constructor",
|
||||
10: "Enum",
|
||||
11: "Interface",
|
||||
12: "Function",
|
||||
13: "Variable",
|
||||
14: "Constant",
|
||||
15: "String",
|
||||
16: "Number",
|
||||
17: "Boolean",
|
||||
18: "Array",
|
||||
19: "Object",
|
||||
20: "Key",
|
||||
21: "Null",
|
||||
22: "EnumMember",
|
||||
23: "Struct",
|
||||
24: "Event",
|
||||
25: "Operator",
|
||||
26: "TypeParameter",
|
||||
}
|
||||
|
||||
export const SEVERITY_MAP: Record<number, string> = {
|
||||
1: "error",
|
||||
2: "warning",
|
||||
@@ -36,8 +7,6 @@ export const SEVERITY_MAP: Record<number, string> = {
|
||||
4: "hint",
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_REFERENCES = 200
|
||||
export const DEFAULT_MAX_SYMBOLS = 200
|
||||
export const DEFAULT_MAX_DIAGNOSTICS = 200
|
||||
|
||||
export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
@@ -80,6 +49,7 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
|
||||
"haskell-language-server": "ghcup install hls",
|
||||
bash: "npm install -g bash-language-server",
|
||||
"kotlin-ls": "See https://github.com/Kotlin/kotlin-lsp",
|
||||
}
|
||||
|
||||
// Synced with OpenCode's server.ts
|
||||
@@ -246,6 +216,10 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
command: ["haskell-language-server-wrapper", "--lsp"],
|
||||
extensions: [".hs", ".lhs"],
|
||||
},
|
||||
"kotlin-ls": {
|
||||
command: ["kotlin-lsp"],
|
||||
extensions: [".kt", ".kts"],
|
||||
},
|
||||
}
|
||||
|
||||
// Synced with OpenCode's language.ts
|
||||
|
||||
@@ -1,206 +1,25 @@
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { getAllServers } from "./config"
|
||||
import {
|
||||
DEFAULT_MAX_REFERENCES,
|
||||
DEFAULT_MAX_SYMBOLS,
|
||||
DEFAULT_MAX_DIAGNOSTICS,
|
||||
} from "./constants"
|
||||
import {
|
||||
withLspClient,
|
||||
formatHoverResult,
|
||||
formatLocation,
|
||||
formatDocumentSymbol,
|
||||
formatSymbolInfo,
|
||||
formatDiagnostic,
|
||||
filterDiagnosticsBySeverity,
|
||||
formatPrepareRenameResult,
|
||||
formatCodeActions,
|
||||
applyWorkspaceEdit,
|
||||
formatApplyResult,
|
||||
} from "./utils"
|
||||
import type {
|
||||
HoverResult,
|
||||
Location,
|
||||
LocationLink,
|
||||
DocumentSymbol,
|
||||
SymbolInfo,
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
WorkspaceEdit,
|
||||
CodeAction,
|
||||
Command,
|
||||
} from "./types"
|
||||
|
||||
|
||||
|
||||
export const lsp_hover: ToolDefinition = tool({
|
||||
description: "Get type info, docs, and signature for a symbol at position.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
line: tool.schema.number().min(1).describe("1-based"),
|
||||
character: tool.schema.number().min(0).describe("0-based"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.hover(args.filePath, args.line, args.character)) as HoverResult | null
|
||||
})
|
||||
const output = formatHoverResult(result)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_goto_definition: ToolDefinition = tool({
|
||||
description: "Jump to symbol definition. Find WHERE something is defined.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
line: tool.schema.number().min(1).describe("1-based"),
|
||||
character: tool.schema.number().min(0).describe("0-based"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.definition(args.filePath, args.line, args.character)) as
|
||||
| Location
|
||||
| Location[]
|
||||
| LocationLink[]
|
||||
| null
|
||||
})
|
||||
|
||||
if (!result) {
|
||||
const output = "No definition found"
|
||||
return output
|
||||
}
|
||||
|
||||
const locations = Array.isArray(result) ? result : [result]
|
||||
if (locations.length === 0) {
|
||||
const output = "No definition found"
|
||||
return output
|
||||
}
|
||||
|
||||
const output = locations.map(formatLocation).join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_find_references: ToolDefinition = tool({
|
||||
description: "Find ALL usages/references of a symbol across the entire workspace.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
line: tool.schema.number().min(1).describe("1-based"),
|
||||
character: tool.schema.number().min(0).describe("0-based"),
|
||||
includeDeclaration: tool.schema.boolean().optional().describe("Include the declaration itself"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.references(args.filePath, args.line, args.character, args.includeDeclaration ?? true)) as
|
||||
| Location[]
|
||||
| null
|
||||
})
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
const output = "No references found"
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_REFERENCES
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
|
||||
const lines = limited.map(formatLocation)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_document_symbols: ToolDefinition = tool({
|
||||
description: "Get hierarchical outline of all symbols in a file.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.documentSymbols(args.filePath)) as DocumentSymbol[] | SymbolInfo[] | null
|
||||
})
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
const output = "No symbols found"
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_SYMBOLS
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_SYMBOLS) : result
|
||||
|
||||
const lines: string[] = []
|
||||
if (truncated) {
|
||||
lines.push(`Found ${total} symbols (showing first ${DEFAULT_MAX_SYMBOLS}):`)
|
||||
}
|
||||
|
||||
if ("range" in limited[0]) {
|
||||
lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
|
||||
} else {
|
||||
lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
|
||||
}
|
||||
return lines.join("\n")
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_workspace_symbols: ToolDefinition = tool({
|
||||
description: "Search symbols by name across ENTIRE workspace.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
query: tool.schema.string().describe("Symbol name (fuzzy match)"),
|
||||
limit: tool.schema.number().optional().describe("Max results"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.workspaceSymbols(args.query)) as SymbolInfo[] | null
|
||||
})
|
||||
|
||||
if (!result || result.length === 0) {
|
||||
const output = "No symbols found"
|
||||
return output
|
||||
}
|
||||
|
||||
const total = result.length
|
||||
const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
|
||||
const truncated = total > limit
|
||||
const limited = result.slice(0, limit)
|
||||
const lines = limited.map(formatSymbolInfo)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} symbols (showing first ${limit}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
// NOTE: lsp_goto_definition, lsp_find_references, lsp_symbols are removed
|
||||
// as they duplicate OpenCode's built-in LSP tools (LspGotoDefinition, LspFindReferences, LspDocumentSymbols, LspWorkspaceSymbols)
|
||||
|
||||
export const lsp_diagnostics: ToolDefinition = tool({
|
||||
description: "Get errors, warnings, hints from language server BEFORE running build.",
|
||||
@@ -317,89 +136,3 @@ export const lsp_rename: ToolDefinition = tool({
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_code_actions: ToolDefinition = tool({
|
||||
description: "Get available quick fixes, refactorings, and source actions (organize imports, fix all).",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
startLine: tool.schema.number().min(1).describe("1-based"),
|
||||
startCharacter: tool.schema.number().min(0).describe("0-based"),
|
||||
endLine: tool.schema.number().min(1).describe("1-based"),
|
||||
endCharacter: tool.schema.number().min(0).describe("0-based"),
|
||||
kind: tool.schema
|
||||
.enum([
|
||||
"quickfix",
|
||||
"refactor",
|
||||
"refactor.extract",
|
||||
"refactor.inline",
|
||||
"refactor.rewrite",
|
||||
"source",
|
||||
"source.organizeImports",
|
||||
"source.fixAll",
|
||||
])
|
||||
.optional()
|
||||
.describe("Filter by code action kind"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const only = args.kind ? [args.kind] : undefined
|
||||
const result = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.codeAction(
|
||||
args.filePath,
|
||||
args.startLine,
|
||||
args.startCharacter,
|
||||
args.endLine,
|
||||
args.endCharacter,
|
||||
only
|
||||
)) as (CodeAction | Command)[] | null
|
||||
})
|
||||
const output = formatCodeActions(result)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const lsp_code_action_resolve: ToolDefinition = tool({
|
||||
description: "Resolve and APPLY a code action from lsp_code_actions.",
|
||||
args: {
|
||||
filePath: tool.schema.string(),
|
||||
codeAction: tool.schema.string().describe("Code action JSON from lsp_code_actions"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const codeAction = JSON.parse(args.codeAction) as CodeAction
|
||||
const resolved = await withLspClient(args.filePath, async (client) => {
|
||||
return (await client.codeActionResolve(codeAction)) as CodeAction | null
|
||||
})
|
||||
|
||||
if (!resolved) {
|
||||
const output = "Failed to resolve code action"
|
||||
return output
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Action: ${resolved.title}`)
|
||||
if (resolved.kind) lines.push(`Kind: ${resolved.kind}`)
|
||||
|
||||
if (resolved.edit) {
|
||||
const result = applyWorkspaceEdit(resolved.edit)
|
||||
lines.push(formatApplyResult(result))
|
||||
} else {
|
||||
lines.push("No edit to apply")
|
||||
}
|
||||
|
||||
if (resolved.command) {
|
||||
lines.push(`Command: ${resolved.command.title} (${resolved.command.command}) - not executed`)
|
||||
}
|
||||
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user