Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e3040ecb28 | ||
|
|
066ab4b303 | ||
|
|
bceeba8ca9 | ||
|
|
d8f10f53d4 | ||
|
|
45076041af | ||
|
|
bcf1d02f13 | ||
|
|
a63f76107b | ||
|
|
7b57364aa2 | ||
|
|
37c92b86e6 | ||
|
|
058e6adf96 | ||
|
|
355f18d411 | ||
|
|
048ed36120 | ||
|
|
ec61350664 | ||
|
|
61251737d4 | ||
|
|
c11aa598d7 | ||
|
|
5138c50a6a | ||
|
|
0f0f49b823 | ||
|
|
c401113537 | ||
|
|
b8efd3c771 | ||
|
|
b92cd6ab68 | ||
|
|
f7696a1fbb | ||
|
|
d33d60fe3b | ||
|
|
64053f1252 | ||
|
|
15419d74c2 | ||
|
|
5e6ae77e73 | ||
|
|
1f1fefe8b7 | ||
|
|
2c778d9352 | ||
|
|
17e8746eff | ||
|
|
7324b6c6b5 | ||
|
|
ca5dac71d9 | ||
|
|
2bdab59f22 | ||
|
|
59507500ea | ||
|
|
3a08dcaeb1 | ||
|
|
c01b21d0f8 | ||
|
|
6dd98254be | ||
|
|
55a3a6c9eb | ||
|
|
765507648c | ||
|
|
c10bc5fcdf | ||
|
|
c0b28b0715 | ||
|
|
dd60002a0d | ||
|
|
25d2946b76 | ||
|
|
122e918503 | ||
|
|
aeff184e0c | ||
|
|
b995ea8595 | ||
|
|
6e5edafeee | ||
|
|
bfb5d43bc2 | ||
|
|
385e8a97b0 | ||
|
|
7daabf9617 | ||
|
|
5fbcb88a3f | ||
|
|
daa5f6ee5b | ||
|
|
4d66ea9730 | ||
|
|
4d4273603a | ||
|
|
7b7c14301e | ||
|
|
e3be656f86 | ||
|
|
c11cb2e3f1 | ||
|
|
195e8dcb17 | ||
|
|
284e7f5bc3 | ||
|
|
465c9e511f | ||
|
|
18d134fa57 | ||
|
|
092718f82d | ||
|
|
19f504fcfa | ||
|
|
49f3be5a1f | ||
|
|
6d6102f1ff | ||
|
|
1d7e534b92 | ||
|
|
17b7dd396e | ||
|
|
889d80d0ca | ||
|
|
87e229fb62 | ||
|
|
78514ec6d4 | ||
|
|
1c12925c9e | ||
|
|
262f0c3f1f | ||
|
|
aace1982ec |
24
.github/workflows/sisyphus-agent.yml
vendored
24
.github/workflows/sisyphus-agent.yml
vendored
@@ -86,14 +86,19 @@ jobs:
|
||||
|
||||
# Install OpenCode (skip if cached)
|
||||
if ! command -v opencode &>/dev/null; then
|
||||
for i in 1 2 3; do
|
||||
echo "Attempt $i: Installing OpenCode..."
|
||||
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
|
||||
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
|
||||
bash /tmp/opencode-install.sh && break
|
||||
echo "Installing OpenCode..."
|
||||
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
|
||||
|
||||
# Try default installer first, fallback to pinned version if it fails
|
||||
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
|
||||
if ! bash /tmp/opencode-install.sh 2>&1; then
|
||||
echo "Default installer failed, trying with pinned version..."
|
||||
bash /tmp/opencode-install.sh --version 1.0.204
|
||||
fi
|
||||
echo "Download corrupted, retrying in 5s..."
|
||||
done
|
||||
else
|
||||
echo "Download corrupted, trying direct install with pinned version..."
|
||||
bash <(curl -fsSL https://opencode.ai/install) --version 1.0.204
|
||||
fi
|
||||
fi
|
||||
opencode --version
|
||||
|
||||
@@ -311,10 +316,9 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
First, acknowledge with `gh issue comment NUMBER_PLACEHOLDER --body "👋 Hey @AUTHOR_PLACEHOLDER! I'm on it..."`
|
||||
|
||||
Then write everything using the todo tools.
|
||||
Write everything using the todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
|
||||
PROMPT_EOF
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2025-12-24T17:07:00+09:00
|
||||
**Commit:** 0172241
|
||||
**Generated:** 2025-12-28T19:26:00+09:00
|
||||
**Commit:** 122e918
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
110
README.ja.md
110
README.ja.md
@@ -390,14 +390,47 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
</details>
|
||||
|
||||
|
||||
## アンインストール
|
||||
|
||||
oh-my-opencode を削除するには:
|
||||
|
||||
1. **OpenCode 設定からプラグインを削除**
|
||||
|
||||
`~/.config/opencode/opencode.json` (または `opencode.jsonc`) を編集し、`plugin` 配列から `"oh-my-opencode"` を削除します:
|
||||
|
||||
```bash
|
||||
# jq を使用する例
|
||||
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
```
|
||||
|
||||
2. **設定ファイルの削除 (オプション)**
|
||||
|
||||
```bash
|
||||
# ユーザー設定を削除
|
||||
rm -f ~/.config/opencode/oh-my-opencode.json
|
||||
|
||||
# プロジェクト設定を削除 (存在する場合)
|
||||
rm -f .opencode/oh-my-opencode.json
|
||||
```
|
||||
|
||||
3. **削除の確認**
|
||||
|
||||
```bash
|
||||
opencode --version
|
||||
# プラグインがロードされなくなっているはずです
|
||||
```
|
||||
|
||||
|
||||
## 機能
|
||||
|
||||
### Agents: あなたの新しいチームメイト
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **デフォルトエージェントです。** OpenCode のための強力な AI オーケストレーターです。専門のサブエージェントを活用して、複雑なタスクを計画、委任、実行します。バックグラウンドタスクへの委任と Todo ベースのワークフローを重視します。最大の推論能力を発揮するため、Claude Opus 4.5 と拡張思考 (32k token budget) を使用します。
|
||||
- **oracle** (`openai/gpt-5.2`): アーキテクチャ、コードレビュー、戦略立案のための専門アドバイザー。GPT-5.2 の卓越した論理的推論と深い分析能力を活用します。AmpCode からインスピレーションを得ました。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
|
||||
- **explore** (`opencode/grok-code`): 高速なコードベース探索、ファイルパターンマッチング。Claude Code は Haiku を使用しますが、私たちは Grok を使います。現在無料であり、極めて高速で、ファイル探索タスクには十分な知能を備えているからです。Claude Code からインスピレーションを得ました。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` または `google/gemini-3-flash`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、それ以外は Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
|
||||
- **explore** (`opencode/grok-code`、`google/gemini-3-flash`、または `anthropic/claude-haiku-4-5`): 高速なコードベース探索、ファイルパターンマッチング。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、Claude max20 が利用可能な場合は Haiku を使用し、それ以外は Grok を使います。Claude Code からインスピレーションを得ました。
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 開発者に転身したデザイナーという設定です。素晴らしい UI を作ります。美しく独創的な UI コードを生成することに長けた Gemini を使用します。
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): テクニカルライティングの専門家という設定です。Gemini は文筆家であり、流れるような文章を書きます。
|
||||
- **multimodal-looker** (`google/gemini-3-flash`): 視覚コンテンツ解釈のための専門エージェント。PDF、画像、図表を分析して情報を抽出します。
|
||||
@@ -457,6 +490,19 @@ Ask @explore for the policy on this feature
|
||||
- **ast_grep_search**: AST 認識コードパターン検索 (25言語対応)
|
||||
- **ast_grep_replace**: AST 認識コード置換
|
||||
|
||||
#### セッション管理
|
||||
|
||||
OpenCode セッション履歴をナビゲートおよび検索するためのツール:
|
||||
|
||||
- **session_list**: 日付およびリミットでフィルタリングしながらすべての OpenCode セッションを一覧表示
|
||||
- **session_read**: 特定のセッションからメッセージと履歴を読み取る
|
||||
- **session_search**: セッションメッセージ全体を全文検索
|
||||
- **session_info**: セッションに関するメタデータと統計情報を取得
|
||||
|
||||
これらのツールにより、エージェントは以前の会話を参照し、セッション間の継続性を維持できます。
|
||||
|
||||
- **call_omo_agent**: 専門的な explore/librarian エージェントを起動。非同期実行のための `run_in_background` パラメータをサポート。
|
||||
|
||||
#### Context Is All You Need
|
||||
- **Directory AGENTS.md / README.md Injector**: ファイルを読み込む際、`AGENTS.md` と `README.md` の内容を自動的に注入します。ファイルディレクトリからプロジェクトルートまで遡り、パス上の **すべて** の `AGENTS.md` ファイルを収集します。ネストされたディレクトリごとの指示をサポートします:
|
||||
```
|
||||
@@ -589,6 +635,12 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
|
||||
エージェントが活躍すれば、あなたも幸せになります。ですが、私はあなた自身も助けたいのです。
|
||||
|
||||
- **Ralph Loop**: タスクが完了するまで実行し続ける自己参照型開発ループ。Anthropic の Ralph Wiggum プラグインにインスパイアされています。**すべてのプログラミング言語をサポート。**
|
||||
- `/ralph-loop "REST API を構築"` で開始するとエージェントが継続的に作業します
|
||||
- `<promise>DONE</promise>` の出力で完了を検知
|
||||
- 完了プロミスなしで停止すると自動再開
|
||||
- 終了条件: 完了検知、最大反復回数到達(デフォルト 100)、または `/cancel-ralph`
|
||||
- `oh-my-opencode.json` で設定: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
|
||||
- **Keyword Detector**: プロンプト内のキーワードを自動検知して専門モードを有効化します:
|
||||
- `ultrawork` / `ulw`: 並列エージェントオーケストレーションによる最大パフォーマンスモード
|
||||
- `search` / `find` / `찾아` / `検索`: 並列 explore/librarian エージェントによる検索最大化
|
||||
@@ -619,7 +671,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
|
||||
| プラットフォーム | ユーザー設定パス |
|
||||
|------------------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (優先) または `%APPDATA%\opencode\oh-my-opencode.json` (フォールバック) |
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (推奨) または `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
スキーマ自動補完がサポートされています:
|
||||
@@ -630,6 +682,36 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC のサポート
|
||||
|
||||
`oh-my-opencode` 設定ファイルは JSONC (コメント付き JSON) をサポートしています:
|
||||
- 行コメント: `// コメント`
|
||||
- ブロックコメント: `/* コメント */`
|
||||
- 末尾のカンマ: `{ "key": "value", }`
|
||||
|
||||
`oh-my-opencode.jsonc` と `oh-my-opencode.json` の両方が存在する場合、`.jsonc` が優先されます。
|
||||
|
||||
**コメント付きの例:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// Antigravity OAuth 経由で Google Gemini を有効にする
|
||||
"google_auth": false,
|
||||
|
||||
/* エージェントのオーバーライド - 特定のタスクに合わせてモデルをカスタマイズ */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // 戦略的な推論のための GPT
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // 探索のための高速かつ無料のモデル
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**推奨**: 外部の [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) プラグインを使用してください。マルチアカウントロードバランシング、より多くのモデル(Antigravity 経由の Claude を含む)、活発なメンテナンスを提供します。[インストール > Google Gemini](#42-google-gemini-antigravity-oauth) を参照。
|
||||
@@ -792,7 +874,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
|
||||
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
|
||||
|
||||
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
|
||||
|
||||
@@ -844,20 +926,22 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
"auto_resume": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化します。 |
|
||||
| `dcp_on_compaction_failure` | `false` | 有効にすると、DCP(Dynamic Context Pruning)はコンパクション(要約)が失敗した後にのみ実行され、その後コンパクションを再試行します。通常時は DCP は実行されません。トークン制限に達した際によりスマートな回復が必要な場合は有効にしてください。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | トークン制限に達する前にセッションを事前にコンパクションします。デフォルトでコンテキストウィンドウ使用率80%で実行されます。 |
|
||||
| `preemptive_compaction_threshold` | `0.80` | プリエンプティブコンパクションをトリガーする閾値(0.5-0.95)。`preemptive_compaction`が有効な場合のみ適用されます。 |
|
||||
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツール(Grep、Glob、LSP、AST-grep)だけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `dcp_for_compaction` | `false` | コンパクション用DCP(動的コンテキスト整理)を有効化 - トークン制限超過時に最初に実行されます。コンパクション前に重複したツール呼び出しと古いツール出力を整理します。 |
|
||||
|
||||
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
|
||||
|
||||
|
||||
113
README.ko.md
113
README.ko.md
@@ -387,14 +387,47 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
</details>
|
||||
|
||||
|
||||
## 언인스톨
|
||||
|
||||
oh-my-opencode를 제거하려면:
|
||||
|
||||
1. **OpenCode 설정에서 플러그인 제거**
|
||||
|
||||
`~/.config/opencode/opencode.json` (또는 `opencode.jsonc`)를 편집하여 `plugin` 배열에서 `"oh-my-opencode"`를 제거합니다:
|
||||
|
||||
```bash
|
||||
# jq 사용 예시
|
||||
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
```
|
||||
|
||||
2. **설정 파일 삭제 (선택 사항)**
|
||||
|
||||
```bash
|
||||
# 사용자 설정 삭제
|
||||
rm -f ~/.config/opencode/oh-my-opencode.json
|
||||
|
||||
# 프로젝트 설정 삭제 (존재하는 경우)
|
||||
rm -f .opencode/oh-my-opencode.json
|
||||
```
|
||||
|
||||
3. **제거 확인**
|
||||
|
||||
```bash
|
||||
opencode --version
|
||||
# 플러그인이 더 이상 로드되지 않아야 합니다
|
||||
```
|
||||
|
||||
|
||||
## 기능
|
||||
|
||||
### Agents: 당신의 새로운 팀원들
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **기본 에이전트입니다.** OpenCode를 위한 강력한 AI 오케스트레이터입니다. 전문 서브에이전트를 활용하여 복잡한 작업을 계획, 위임, 실행합니다. 백그라운드 태스크 위임과 todo 기반 워크플로우를 강조합니다. 최대 추론 능력을 위해 Claude Opus 4.5와 확장된 사고(32k 버짓)를 사용합니다.
|
||||
- **oracle** (`openai/gpt-5.2`): 아키텍처, 코드 리뷰, 전략 수립을 위한 전문가 조언자. GPT-5.2의 뛰어난 논리적 추론과 깊은 분석 능력을 활용합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **explore** (`opencode/grok-code`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Claude Code는 Haiku를 쓰지만, 우리는 Grok을 씁니다. 현재 무료이고, 극도로 빠르며, 파일 탐색 작업에 충분한 지능을 갖췄기 때문입니다. Claude Code 에서 영감을 받았습니다.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` 또는 `google/gemini-3-flash`): 멀티 레포 분석, 문서 조회, 구현 예제 담당. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, 그렇지 않으면 Claude Sonnet 4.5를 사용하여 깊은 코드베이스 이해와 GitHub 조사, 근거 기반의 답변을 제공합니다. AmpCode 에서 영감을 받았습니다.
|
||||
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, 또는 `anthropic/claude-haiku-4-5`): 빠른 코드베이스 탐색, 파일 패턴 매칭. Antigravity 인증이 설정된 경우 Gemini 3 Flash를 사용하고, Claude max20이 있으면 Haiku를 사용하며, 그 외에는 Grok을 씁니다. Claude Code 에서 영감을 받았습니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
|
||||
- **multimodal-looker** (`google/gemini-3-flash`): 시각적 콘텐츠 해석을 위한 전문 에이전트. PDF, 이미지, 다이어그램을 분석하여 정보를 추출합니다.
|
||||
@@ -450,6 +483,18 @@ Syntax Highlighting, Autocomplete, Refactoring, Navigation, Analysis, 그리고
|
||||
- **lsp_code_action_resolve**: 코드 액션 적용
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
- **call_omo_agent**: 전문 explore/librarian 에이전트를 생성합니다. 비동기 실행을 위한 `run_in_background` 파라미터를 지원합니다.
|
||||
|
||||
#### 세션 관리 (Session Management)
|
||||
|
||||
OpenCode 세션 히스토리를 탐색하고 검색하기 위한 도구들입니다:
|
||||
|
||||
- **session_list**: 날짜 및 개수 제한 필터링을 포함한 모든 OpenCode 세션 목록 조회
|
||||
- **session_read**: 특정 세션의 메시지 및 히스토리 읽기
|
||||
- **session_search**: 세션 메시지 전체 텍스트 검색
|
||||
- **session_info**: 세션에 대한 메타데이터 및 통계 정보 조회
|
||||
|
||||
이 도구들을 통해 에이전트는 이전 대화를 참조하고 세션 간의 연속성을 유지할 수 있습니다.
|
||||
|
||||
#### Context is all you need.
|
||||
- **Directory AGENTS.md / README.md Injector**: 파일을 읽을 때 `AGENTS.md`, `README.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
@@ -583,6 +628,12 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
|
||||
에이전트들이 행복해지면, 당신이 제일 행복해집니다, 그렇지만 저는 당신도 돕고싶습니다.
|
||||
|
||||
- **Ralph Loop**: 작업이 완료될 때까지 계속 실행되는 자기 참조 개발 루프. Anthropic의 Ralph Wiggum 플러그인에서 영감을 받았습니다. **모든 프로그래밍 언어 지원.**
|
||||
- `/ralph-loop "REST API 구축"`으로 시작하면 에이전트가 지속적으로 작업합니다
|
||||
- `<promise>DONE</promise>` 출력 시 완료로 감지
|
||||
- 완료 프라미스 없이 멈추면 자동 재시작
|
||||
- 종료 조건: 완료 감지, 최대 반복 도달 (기본 100회), 또는 `/cancel-ralph`
|
||||
- `oh-my-opencode.json`에서 설정: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
|
||||
- **Keyword Detector**: 프롬프트의 키워드를 자동 감지하여 전문 모드를 활성화합니다:
|
||||
- `ultrawork` / `ulw`: 병렬 에이전트 오케스트레이션으로 최대 성능 모드
|
||||
- `search` / `find` / `찾아` / `検索`: 병렬 explore/librarian 에이전트로 검색 극대화
|
||||
@@ -602,6 +653,10 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
- **Empty Message Sanitizer**: 빈 채팅 메시지로 인한 API 오류를 방지합니다. 전송 전 메시지 내용을 자동으로 정리합니다.
|
||||
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
|
||||
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
|
||||
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 문제가 발생하기 전에 미리 실행됩니다.
|
||||
- **압축 컨텍스트 주입기 (Compaction Context Injector)**: 세션 압축 중에 중요한 컨텍스트(AGENTS.md, 현재 디렉토리 정보 등)를 유지하여 중요한 상태를 잃지 않도록 합니다.
|
||||
- **사고 블록 검증기 (Thinking Block Validator)**: 사고(thinking) 블록의 형식이 올바른지 검증하여 잘못된 형식으로 인한 API 오류를 방지합니다.
|
||||
- **Claude Code 훅 (Claude Code Hooks)**: Claude Code의 settings.json에 설정된 훅을 실행합니다. PreToolUse/PostToolUse/UserPromptSubmit/Stop 이벤트를 지원하는 호환성 레이어입니다.
|
||||
|
||||
## 설정
|
||||
|
||||
@@ -613,7 +668,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
|
||||
| 플랫폼 | 사용자 설정 경로 |
|
||||
|--------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (우선) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (권장) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
Schema 자동 완성이 지원됩니다:
|
||||
@@ -624,6 +679,36 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC 지원
|
||||
|
||||
`oh-my-opencode` 설정 파일은 JSONC(주석이 포함된 JSON)를 지원합니다:
|
||||
- 한 줄 주석: `// 주석`
|
||||
- 블록 주석: `/* 주석 */`
|
||||
- 후행 콤마(Trailing commas): `{ "key": "value", }`
|
||||
|
||||
`oh-my-opencode.jsonc`와 `oh-my-opencode.json` 파일이 모두 존재할 경우, `.jsonc` 파일이 우선순위를 갖습니다.
|
||||
|
||||
**주석이 포함된 예시:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// Antigravity OAuth를 통해 Google Gemini 활성화
|
||||
"google_auth": false,
|
||||
|
||||
/* 에이전트 오버라이드 - 특정 작업에 대한 모델 커스터마이징 */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // 전략적 추론을 위한 GPT
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // 탐색을 위한 빠르고 무료인 모델
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**권장**: 외부 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 플러그인을 사용하세요. 멀티 계정 로드밸런싱, 더 많은 모델(Antigravity를 통한 Claude 포함), 활발한 유지보수를 제공합니다. [설치 > Google Gemini](#42-google-gemini-antigravity-oauth) 참조.
|
||||
@@ -786,7 +871,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
|
||||
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
|
||||
|
||||
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
|
||||
|
||||
@@ -838,20 +923,22 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
"auto_resume": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
|
||||
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
|
||||
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
|
||||
| `dcp_on_compaction_failure` | `false` | 활성화하면, DCP(Dynamic Context Pruning)가 compaction(요약) 실패 후에만 실행되고 compaction을 재시도합니다. DCP는 평소에는 실행되지 않습니다. 토큰 제한에 도달했을 때 더 스마트한 복구를 원하면 활성화하세요. |
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | 토큰 제한에 도달하기 전에 세션을 미리 컴팩션합니다. 기본적으로 컨텍스트 윈도우 사용량이 80%일 때 실행됩니다. |
|
||||
| `preemptive_compaction_threshold` | `0.80` | 선제적 컴팩션을 트리거할 임계값 비율(0.5-0.95). `preemptive_compaction`이 활성화된 경우에만 적용됩니다. |
|
||||
| `truncate_all_tool_outputs` | `false` | 화이트리스트 도구(Grep, Glob, LSP, AST-grep)만이 아닌 모든 도구 출력을 잘라냅니다. Tool output truncator는 기본적으로 활성화됩니다 - `disabled_hooks`로 비활성화 가능합니다. |
|
||||
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
|
||||
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
|
||||
| `dcp_for_compaction` | `false` | 컴팩션용 DCP(동적 컨텍스트 정리) 활성화 - 토큰 제한 초과 시 먼저 실행됩니다. 컴팩션 전에 중복 도구 호출과 오래된 도구 출력을 정리합니다. |
|
||||
|
||||
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.
|
||||
|
||||
|
||||
40
README.md
40
README.md
@@ -465,8 +465,8 @@ To remove oh-my-opencode:
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **The default agent.** A powerful AI orchestrator for OpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow. Uses Claude Opus 4.5 with extended thinking (32k budget) for maximum reasoning capability.
|
||||
- **oracle** (`openai/gpt-5.2`): Architecture, code review, strategy. Uses GPT-5.2 for its stellar logical reasoning and deep analysis. Inspired by AmpCode.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`): Multi-repo analysis, doc lookup, implementation examples. Uses Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`): Fast codebase exploration and pattern matching. Claude Code uses Haiku; we use Grok—it's free, blazing fast, and plenty smart for file traversal. Inspired by Claude Code.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` or `google/gemini-3-flash`): Multi-repo analysis, doc lookup, implementation examples. Uses Gemini 3 Flash when Antigravity auth is configured, otherwise Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, or `anthropic/claude-haiku-4-5`): Fast codebase exploration and pattern matching. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **document-writer** (`google/gemini-3-flash`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
|
||||
- **multimodal-looker** (`google/gemini-3-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
|
||||
@@ -522,6 +522,18 @@ Hand your best tools to your best colleagues. Now they can properly refactor, na
|
||||
- **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.
|
||||
|
||||
#### Session Management
|
||||
|
||||
Tools to navigate and search your OpenCode session history:
|
||||
|
||||
- **session_list**: List all OpenCode sessions with filtering by date and limit
|
||||
- **session_read**: Read messages and history from a specific session
|
||||
- **session_search**: Full-text search across session messages
|
||||
- **session_info**: Get metadata and statistics about a session
|
||||
|
||||
These tools enable agents to reference previous conversations and maintain continuity across sessions.
|
||||
|
||||
#### Context Is All You Need
|
||||
- **Directory AGENTS.md / README.md Injector**: Auto-injects `AGENTS.md` and `README.md` when reading files. Walks from file directory to project root, collecting **all** `AGENTS.md` files along the path. Supports nested directory-specific instructions:
|
||||
@@ -655,6 +667,12 @@ All toggles default to `true` (enabled). Omit the `claude_code` object for full
|
||||
|
||||
When agents thrive, you thrive. But I want to help you directly too.
|
||||
|
||||
- **Ralph Loop**: Self-referential development loop that runs until task completion. Inspired by Anthropic's Ralph Wiggum plugin. **Supports all programming languages.**
|
||||
- Start with `/ralph-loop "Build a REST API"` and let the agent work continuously
|
||||
- Loop detects `<promise>DONE</promise>` to know when complete
|
||||
- Auto-continues if agent stops without completion promise
|
||||
- Ends when: completion detected, max iterations reached (default 100), or `/cancel-ralph`
|
||||
- Configure in `oh-my-opencode.json`: `{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
|
||||
- **Keyword Detector**: Automatically detects keywords in your prompts and activates specialized modes:
|
||||
- `ultrawork` / `ulw`: Maximum performance mode with parallel agent orchestration
|
||||
- `search` / `find` / `찾아` / `検索`: Maximized search effort with parallel explore and librarian agents
|
||||
@@ -674,6 +692,10 @@ When agents thrive, you thrive. But I want to help you directly too.
|
||||
- **Empty Message Sanitizer**: Prevents API errors from empty chat messages by automatically sanitizing message content before sending.
|
||||
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
|
||||
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
|
||||
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs before you get into trouble.
|
||||
- **Compaction Context Injector**: Preserves critical context (AGENTS.md, current directory info) during session compaction so you don't lose important state.
|
||||
- **Thinking Block Validator**: Validates thinking blocks to ensure proper formatting and prevent API errors from malformed thinking content.
|
||||
- **Claude Code Hooks**: Executes hooks from Claude Code's settings.json - this is the compatibility layer that runs PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -888,7 +910,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
|
||||
}
|
||||
```
|
||||
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
|
||||
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
|
||||
|
||||
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
|
||||
|
||||
@@ -940,20 +962,22 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
"auto_resume": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | Compacts session proactively before hitting hard token limits. Runs at 80% context window usage by default. |
|
||||
| `preemptive_compaction_threshold` | `0.80` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. Only applies when `preemptive_compaction` is enabled. |
|
||||
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
|
||||
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
|
||||
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
|
||||
| `truncate_all_tool_outputs` | `true` | Dynamically truncates ALL tool outputs based on context window usage to prevent prompts from becoming too long. Disable by setting to `false` if you need full tool outputs. |
|
||||
| `dcp_on_compaction_failure` | `false` | When enabled, Dynamic Context Pruning (DCP) runs only after compaction (summarize) fails, then retries compaction. DCP does NOT run during normal operations. Enable this for smarter recovery when hitting token limits. |
|
||||
| `dcp_for_compaction` | `false` | Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded. Prunes duplicate tool calls and old tool outputs before running compaction. |
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
|
||||
114
README.zh-cn.md
114
README.zh-cn.md
@@ -398,14 +398,47 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
</details>
|
||||
|
||||
|
||||
## 卸载
|
||||
|
||||
要移除 oh-my-opencode:
|
||||
|
||||
1. **从 OpenCode 配置中移除插件**
|
||||
|
||||
编辑 `~/.config/opencode/opencode.json` (或 `opencode.jsonc`),从 `plugin` 数组中移除 `"oh-my-opencode"`:
|
||||
|
||||
```bash
|
||||
# 使用 jq 的示例
|
||||
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
```
|
||||
|
||||
2. **删除配置文件 (可选)**
|
||||
|
||||
```bash
|
||||
# 删除用户配置
|
||||
rm -f ~/.config/opencode/oh-my-opencode.json
|
||||
|
||||
# 删除项目配置 (如果存在)
|
||||
rm -f .opencode/oh-my-opencode.json
|
||||
```
|
||||
|
||||
3. **确认移除**
|
||||
|
||||
```bash
|
||||
opencode --version
|
||||
# 插件不应再被加载
|
||||
```
|
||||
|
||||
|
||||
## 功能
|
||||
|
||||
### Agents:你的神队友
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`):**默认 Agent。** OpenCode 专属的强力 AI 编排器。指挥专业子 Agent 搞定复杂任务。主打后台任务委派和 Todo 驱动。用 Claude Opus 4.5 加上扩展思考(32k token 预算),智商拉满。
|
||||
- **oracle** (`openai/gpt-5.2`):架构师、代码审查员、战略家。GPT-5.2 的逻辑推理和深度分析能力不是盖的。致敬 AmpCode。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5`):多仓库分析、查文档、找示例。Claude Sonnet 4.5 深入理解代码库,GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
|
||||
- **explore** (`opencode/grok-code`):极速代码库扫描、模式匹配。Claude Code 用 Haiku,我们用 Grok——免费、飞快、扫文件够用了。致敬 Claude Code。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` 或 `google/gemini-3-flash`):多仓库分析、查文档、找示例。配置 Antigravity 认证时使用 Gemini 3 Flash,否则使用 Claude Sonnet 4.5 深入理解代码库,GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
|
||||
- **explore** (`opencode/grok-code`、`google/gemini-3-flash` 或 `anthropic/claude-haiku-4-5`):极速代码库扫描、模式匹配。配置 Antigravity 认证时使用 Gemini 3 Flash,Claude max20 可用时使用 Haiku,否则用 Grok。致敬 Claude Code。
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`):设计师出身的程序员。UI 做得那是真漂亮。Gemini 写这种创意美观的代码是一绝。
|
||||
- **document-writer** (`google/gemini-3-pro-preview`):技术写作专家。Gemini 文笔好,写出来的东西读着顺畅。
|
||||
- **multimodal-looker** (`google/gemini-3-flash`):视觉内容专家。PDF、图片、图表,看一眼就知道里头有啥。
|
||||
@@ -461,6 +494,18 @@ OhMyOpenCode 让这些成为可能。
|
||||
- **lsp_code_action_resolve**:应用代码操作
|
||||
- **ast_grep_search**:AST 感知代码搜索(支持 25 种语言)
|
||||
- **ast_grep_replace**:AST 感知代码替换
|
||||
- **call_omo_agent**: 产生专门的 explore/librarian Agent。支持用于异步执行的 `run_in_background` 参数。
|
||||
|
||||
#### 会话管理 (Session Management)
|
||||
|
||||
用于导航和搜索 OpenCode 会话历史的工具:
|
||||
|
||||
- **session_list**: 列出所有 OpenCode 会话,支持按日期和数量限制进行过滤
|
||||
- **session_read**: 读取特定会话的消息和历史记录
|
||||
- **session_search**: 在会话消息中进行全文搜索
|
||||
- **session_info**: 获取有关会话的元数据和统计信息
|
||||
|
||||
这些工具使 Agent 能够引用之前的对话并保持跨会话的连续性。
|
||||
|
||||
#### 上下文就是一切 (Context is all you need)
|
||||
- **Directory AGENTS.md / README.md 注入器**:读文件时自动把 `AGENTS.md` 和 `README.md` 塞进去。从当前目录一路往上找,路径上**所有** `AGENTS.md` 全都带上。支持嵌套指令:
|
||||
@@ -594,6 +639,12 @@ Oh My OpenCode 会扫这些地方:
|
||||
|
||||
Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
|
||||
- **Ralph 循环**:干到完事才停的自参照开发循环。灵感来自 Anthropic 的 Ralph Wiggum 插件。**支持所有编程语言。**
|
||||
- `/ralph-loop "搞个 REST API"` 开始,Agent 就一直干
|
||||
- 检测到 `<promise>DONE</promise>` 就算完事
|
||||
- 没输出完成标记就停了?自动续上
|
||||
- 停止条件:检测到完成、达到最大迭代(默认 100 次)、或 `/cancel-ralph`
|
||||
- `oh-my-opencode.json` 配置:`{ "ralph_loop": { "enabled": true, "default_max_iterations": 100 } }`
|
||||
- **关键词检测器**:看到关键词自动切模式:
|
||||
- `ultrawork` / `ulw`:并行 Agent 编排,火力全开
|
||||
- `search` / `find` / `찾아` / `検索`:explore/librarian 并行搜索,掘地三尺
|
||||
@@ -620,7 +671,12 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
|
||||
配置文件(优先级从高到低):
|
||||
1. `.opencode/oh-my-opencode.json`(项目级)
|
||||
2. `~/.config/opencode/oh-my-opencode.json`(用户级)
|
||||
2. 用户配置(按平台):
|
||||
|
||||
| 平台 | 用户配置路径 |
|
||||
|----------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (首选) 或 `%APPDATA%\opencode\oh-my-opencode.json` (备选) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
支持 Schema 自动补全:
|
||||
|
||||
@@ -630,6 +686,36 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
### JSONC 支持
|
||||
|
||||
`oh-my-opencode` 配置文件支持 JSONC(带注释的 JSON):
|
||||
- 行注释:`// 注释`
|
||||
- 块注释:`/* 注释 */`
|
||||
- 尾随逗号:`{ "key": "value", }`
|
||||
|
||||
当 `oh-my-opencode.jsonc` 和 `oh-my-opencode.json` 文件同时存在时,`.jsonc` 优先。
|
||||
|
||||
**带注释的示例:**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
|
||||
// 通过 Antigravity OAuth 启用 Google Gemini
|
||||
"google_auth": false,
|
||||
|
||||
/* Agent 覆盖 - 为特定任务自定义模型 */
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "openai/gpt-5.2" // 用于战略推理的 GPT
|
||||
},
|
||||
"explore": {
|
||||
"model": "opencode/grok-code" // 快速且免费的搜索模型
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### Google Auth
|
||||
|
||||
**强推**:用外部 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 插件。多账号负载均衡、更多模型(包括 Antigravity 版 Claude)、有人维护。看 [安装 > Google Gemini](#42-google-gemini-antigravity-oauth)。
|
||||
@@ -792,7 +878,7 @@ Sisyphus Agent 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
可关的 hook:`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-auto-compact`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`
|
||||
可关的 hook:`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`
|
||||
|
||||
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
|
||||
|
||||
@@ -844,20 +930,22 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
```json
|
||||
{
|
||||
"experimental": {
|
||||
"preemptive_compaction": true,
|
||||
"truncate_all_tool_outputs": true,
|
||||
"aggressive_truncation": true,
|
||||
"auto_resume": true,
|
||||
"truncate_all_tool_outputs": false,
|
||||
"dcp_on_compaction_failure": true
|
||||
"auto_resume": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
|
||||
| `dcp_on_compaction_failure` | `false` | 启用后,DCP(动态上下文剪枝)仅在压缩(摘要)失败后运行,然后重试压缩。平时 DCP 不会运行。当达到 token 限制时需要更智能的恢复请启用此选项。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction` | `false` | 在达到 token 限制之前主动压缩会话。默认在上下文窗口使用率达到 80% 时运行。 |
|
||||
| `preemptive_compaction_threshold` | `0.80` | 触发预先压缩的阈值比例(0.5-0.95)。仅在 `preemptive_compaction` 启用时生效。 |
|
||||
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出,而不仅仅是白名单工具(Grep、Glob、LSP、AST-grep)。Tool output truncator 默认启用 - 使用 `disabled_hooks` 禁用。 |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| `dcp_for_compaction` | `false` | 启用压缩用 DCP(动态上下文剪枝)- 在超出 token 限制时首先执行。在压缩前清理重复的工具调用和旧的工具输出。 |
|
||||
|
||||
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"directory-readme-injector",
|
||||
"empty-task-response-detector",
|
||||
"think-mode",
|
||||
"anthropic-auto-compact",
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"rules-injector",
|
||||
"background-notification",
|
||||
"auto-update-checker",
|
||||
@@ -60,7 +60,17 @@
|
||||
"non-interactive-env",
|
||||
"interactive-bash-session",
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator"
|
||||
"thinking-block-validator",
|
||||
"ralph-loop"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_commands": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"init-deep"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -1375,6 +1385,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"comment_checker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"custom_prompt": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"experimental": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -1393,7 +1411,6 @@
|
||||
"maximum": 0.95
|
||||
},
|
||||
"truncate_all_tool_outputs": {
|
||||
"default": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"dynamic_context_pruning": {
|
||||
@@ -1487,13 +1504,150 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"dcp_on_compaction_failure": {
|
||||
"dcp_for_compaction": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"auto_update": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"skills": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"allOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"template": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"agent": {
|
||||
"type": "string"
|
||||
},
|
||||
"subtask": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"argument-hint": {
|
||||
"type": "string"
|
||||
},
|
||||
"license": {
|
||||
"type": "string"
|
||||
},
|
||||
"compatibility": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"allowed-tools": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sources": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"recursive": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"glob": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"path"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"enable": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"ralph_loop": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"default_max_iterations": {
|
||||
"default": 100,
|
||||
"type": "number",
|
||||
"minimum": 1,
|
||||
"maximum": 1000
|
||||
},
|
||||
"state_dir": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
4
bun.lock
4
bun.lock
@@ -8,7 +8,7 @@
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
"@clack/prompts": ["@clack/prompts@0.11.0", "", { "dependencies": { "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.0", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-VtDPrhbUJcb5BIS18VMcY/N/xSLbMr6dpU9MO1NYQyEDhI4pSIx07K4gOlCutG/nHVCjO+HEarn8rttODP+5UA=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.6.0",
|
||||
"version": "2.8.2",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -23,7 +23,7 @@
|
||||
"./schema.json": "./dist/oh-my-opencode.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts src/google-auth.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 && bun run build:schema",
|
||||
"build": "bun build src/index.ts src/google-auth.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:schema": "bun run script/build-schema.ts",
|
||||
"clean": "rm -rf dist",
|
||||
"prepublishOnly": "bun run clean && bun run build",
|
||||
@@ -53,7 +53,7 @@
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@clack/prompts": "^0.11.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.0",
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
|
||||
@@ -55,6 +55,54 @@
|
||||
"created_at": "2025-12-27T14:49:05Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 281
|
||||
},
|
||||
{
|
||||
"name": "devxoul",
|
||||
"id": 931655,
|
||||
"comment_id": 3694098760,
|
||||
"created_at": "2025-12-27T17:05:50Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 288
|
||||
},
|
||||
{
|
||||
"name": "SyedTahirHussan",
|
||||
"id": 9879266,
|
||||
"comment_id": 3694598917,
|
||||
"created_at": "2025-12-28T09:24:03Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 306
|
||||
},
|
||||
{
|
||||
"name": "Fguedes90",
|
||||
"id": 13650239,
|
||||
"comment_id": 3695136375,
|
||||
"created_at": "2025-12-28T23:34:19Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 319
|
||||
},
|
||||
{
|
||||
"name": "marcusrbrown",
|
||||
"id": 831617,
|
||||
"comment_id": 3698181444,
|
||||
"created_at": "2025-12-30T03:12:47Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 336
|
||||
},
|
||||
{
|
||||
"name": "lgandecki",
|
||||
"id": 4002543,
|
||||
"comment_id": 3698538417,
|
||||
"created_at": "2025-12-30T07:35:08Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 341
|
||||
},
|
||||
{
|
||||
"name": "purelledhand",
|
||||
"id": 13747937,
|
||||
"comment_id": 3699148046,
|
||||
"created_at": "2025-12-30T12:04:59Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 349
|
||||
}
|
||||
]
|
||||
}
|
||||
89
src/agents/AGENTS.md
Normal file
89
src/agents/AGENTS.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# AGENTS KNOWLEDGE BASE
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
```
|
||||
agents/
|
||||
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
|
||||
├── oracle.ts # Strategic advisor (GPT-5.2)
|
||||
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
|
||||
├── explore.ts # Fast codebase grep (Grok Code)
|
||||
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
|
||||
├── document-writer.ts # Technical docs (Gemini 3 Flash)
|
||||
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
|
||||
├── build-prompt.ts # Shared build agent prompt
|
||||
├── plan-prompt.ts # Shared plan agent prompt
|
||||
├── types.ts # AgentModelConfig interface
|
||||
├── utils.ts # createBuiltinAgents(), getAgentName()
|
||||
└── index.ts # builtinAgents export
|
||||
```
|
||||
|
||||
## AGENT MODELS
|
||||
|
||||
| Agent | Default Model | Fallback | Purpose |
|
||||
|-------|---------------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
|
||||
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
|
||||
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
|
||||
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
|
||||
|
||||
## HOW TO ADD AN AGENT
|
||||
|
||||
1. Create `src/agents/my-agent.ts`:
|
||||
```typescript
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export const myAgent: AgentConfig = {
|
||||
model: "provider/model-name",
|
||||
temperature: 0.1,
|
||||
system: "Agent system prompt...",
|
||||
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
|
||||
}
|
||||
```
|
||||
2. Add to `builtinAgents` in `src/agents/index.ts`
|
||||
3. Update `types.ts` if adding new config options
|
||||
|
||||
## AGENT CONFIG OPTIONS
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| model | string | Model identifier (provider/model-name) |
|
||||
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
|
||||
| system | string | System prompt (can be multiline template literal) |
|
||||
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
|
||||
| top_p | number | Optional nucleus sampling |
|
||||
| maxTokens | number | Optional max output tokens |
|
||||
|
||||
## MODEL FALLBACK LOGIC
|
||||
|
||||
`createBuiltinAgents()` in utils.ts handles model fallback:
|
||||
|
||||
1. Check user config override (`agents.{name}.model`)
|
||||
2. Check installer settings (claude max20, gemini antigravity)
|
||||
3. Use default model
|
||||
|
||||
**Fallback order for explore**:
|
||||
- If gemini antigravity enabled → `google/gemini-3-flash`
|
||||
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
|
||||
- Default → `opencode/grok-code` (free)
|
||||
|
||||
## ANTI-PATTERNS (AGENTS)
|
||||
|
||||
- **High temperature**: Don't use >0.3 for code-related agents
|
||||
- **Broad tool access**: Prefer explicit `include` over unrestricted access
|
||||
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
|
||||
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
|
||||
|
||||
## SHARED PROMPTS
|
||||
|
||||
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
|
||||
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
|
||||
|
||||
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.
|
||||
@@ -17,16 +17,15 @@
|
||||
* Debug logging available via ANTIGRAVITY_DEBUG=1 environment variable.
|
||||
*/
|
||||
|
||||
import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_DEFAULT_PROJECT_ID } from "./constants"
|
||||
import { fetchProjectContext, clearProjectContextCache } from "./project"
|
||||
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage } from "./token"
|
||||
import { ANTIGRAVITY_ENDPOINT_FALLBACKS } from "./constants"
|
||||
import { fetchProjectContext, clearProjectContextCache, invalidateProjectContextByRefreshToken } from "./project"
|
||||
import { isTokenExpired, refreshAccessToken, parseStoredToken, formatTokenForStorage, AntigravityTokenRefreshError } from "./token"
|
||||
import { transformRequest } from "./request"
|
||||
import { convertRequestBody, hasOpenAIMessages } from "./message-converter"
|
||||
import {
|
||||
transformResponse,
|
||||
transformStreamingResponse,
|
||||
isStreamingResponse,
|
||||
extractSignatureFromSsePayload,
|
||||
} from "./response"
|
||||
import { normalizeToolsForGemini, type OpenAITool } from "./tools"
|
||||
import { extractThinkingBlocks, shouldIncludeThinking, transformResponseThinking } from "./thinking"
|
||||
@@ -391,7 +390,6 @@ export function createAntigravityFetch(
|
||||
try {
|
||||
const newTokens = await refreshAccessToken(refreshParts.refreshToken, clientId, clientSecret)
|
||||
|
||||
// Update cached tokens
|
||||
cachedTokens = {
|
||||
type: "antigravity",
|
||||
access_token: newTokens.access_token,
|
||||
@@ -400,10 +398,8 @@ export function createAntigravityFetch(
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
|
||||
// Clear project context cache on token refresh
|
||||
clearProjectContextCache()
|
||||
|
||||
// Format and save new tokens
|
||||
const formattedRefresh = formatTokenForStorage(
|
||||
newTokens.refresh_token,
|
||||
refreshParts.projectId || "",
|
||||
@@ -418,6 +414,16 @@ export function createAntigravityFetch(
|
||||
|
||||
debugLog("Token refreshed successfully")
|
||||
} catch (error) {
|
||||
if (error instanceof AntigravityTokenRefreshError) {
|
||||
if (error.isInvalidGrant) {
|
||||
debugLog(`[REFRESH] Token revoked (invalid_grant), clearing caches`)
|
||||
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
|
||||
clearProjectContextCache()
|
||||
}
|
||||
throw new Error(
|
||||
`Antigravity: Token refresh failed: ${error.description || error.message}${error.code ? ` (${error.code})` : ""}`
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`Antigravity: Token refresh failed: ${error instanceof Error ? error.message : "Unknown error"}`
|
||||
)
|
||||
@@ -535,11 +541,33 @@ export function createAntigravityFetch(
|
||||
debugLog("[401] Token refreshed, retrying request...")
|
||||
return executeWithEndpoints()
|
||||
} catch (refreshError) {
|
||||
if (refreshError instanceof AntigravityTokenRefreshError) {
|
||||
if (refreshError.isInvalidGrant) {
|
||||
debugLog(`[401] Token revoked (invalid_grant), clearing caches`)
|
||||
invalidateProjectContextByRefreshToken(refreshParts.refreshToken)
|
||||
clearProjectContextCache()
|
||||
}
|
||||
debugLog(`[401] Token refresh failed: ${refreshError.description || refreshError.message}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: refreshError.description || refreshError.message,
|
||||
type: refreshError.isInvalidGrant ? "token_revoked" : "unauthorized",
|
||||
code: refreshError.code || "token_refresh_failed",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
)
|
||||
}
|
||||
debugLog(`[401] Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: `Token refresh failed: ${refreshError instanceof Error ? refreshError.message : "Unknown error"}`,
|
||||
message: refreshError instanceof Error ? refreshError.message : "Unknown error",
|
||||
type: "unauthorized",
|
||||
code: "token_refresh_failed",
|
||||
},
|
||||
|
||||
@@ -267,3 +267,8 @@ export function clearProjectContextCache(accessToken?: string): void {
|
||||
projectContextCache.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export function invalidateProjectContextByRefreshToken(_refreshToken: string): void {
|
||||
projectContextCache.clear()
|
||||
debugLog(`[invalidateProjectContextByRefreshToken] Cleared all project context cache due to refresh token invalidation`)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
/**
|
||||
* Antigravity token management utilities.
|
||||
* Handles token expiration checking, refresh, and storage format parsing.
|
||||
*/
|
||||
|
||||
import {
|
||||
ANTIGRAVITY_CLIENT_ID,
|
||||
ANTIGRAVITY_CLIENT_SECRET,
|
||||
@@ -13,33 +8,86 @@ import type {
|
||||
AntigravityRefreshParts,
|
||||
AntigravityTokenExchangeResult,
|
||||
AntigravityTokens,
|
||||
OAuthErrorPayload,
|
||||
ParsedOAuthError,
|
||||
} from "./types"
|
||||
|
||||
/**
|
||||
* Check if the access token is expired.
|
||||
* Includes a 60-second safety buffer to refresh before actual expiration.
|
||||
*
|
||||
* @param tokens - The Antigravity tokens to check
|
||||
* @returns true if the token is expired or will expire within the buffer period
|
||||
*/
|
||||
export function isTokenExpired(tokens: AntigravityTokens): boolean {
|
||||
// Calculate when the token expires (timestamp + expires_in in ms)
|
||||
// timestamp is in milliseconds, expires_in is in seconds
|
||||
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
|
||||
export class AntigravityTokenRefreshError extends Error {
|
||||
code?: string
|
||||
description?: string
|
||||
status: number
|
||||
statusText: string
|
||||
responseBody?: string
|
||||
|
||||
// Check if current time is past (expiration - buffer)
|
||||
constructor(options: {
|
||||
message: string
|
||||
code?: string
|
||||
description?: string
|
||||
status: number
|
||||
statusText: string
|
||||
responseBody?: string
|
||||
}) {
|
||||
super(options.message)
|
||||
this.name = "AntigravityTokenRefreshError"
|
||||
this.code = options.code
|
||||
this.description = options.description
|
||||
this.status = options.status
|
||||
this.statusText = options.statusText
|
||||
this.responseBody = options.responseBody
|
||||
}
|
||||
|
||||
get isInvalidGrant(): boolean {
|
||||
return this.code === "invalid_grant"
|
||||
}
|
||||
|
||||
get isNetworkError(): boolean {
|
||||
return this.status === 0
|
||||
}
|
||||
}
|
||||
|
||||
function parseOAuthErrorPayload(text: string | undefined): ParsedOAuthError {
|
||||
if (!text) {
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(text) as OAuthErrorPayload
|
||||
let code: string | undefined
|
||||
|
||||
if (typeof payload.error === "string") {
|
||||
code = payload.error
|
||||
} else if (payload.error && typeof payload.error === "object") {
|
||||
code = payload.error.status ?? payload.error.code
|
||||
}
|
||||
|
||||
return {
|
||||
code,
|
||||
description: payload.error_description,
|
||||
}
|
||||
} catch {
|
||||
return { description: text }
|
||||
}
|
||||
}
|
||||
|
||||
export function isTokenExpired(tokens: AntigravityTokens): boolean {
|
||||
const expirationTime = tokens.timestamp + tokens.expires_in * 1000
|
||||
return Date.now() >= expirationTime - ANTIGRAVITY_TOKEN_REFRESH_BUFFER_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a refresh token.
|
||||
* Exchanges the refresh token for a new access token via Google's OAuth endpoint.
|
||||
*
|
||||
* @param refreshToken - The refresh token to use
|
||||
* @param clientId - Optional custom client ID (defaults to ANTIGRAVITY_CLIENT_ID)
|
||||
* @param clientSecret - Optional custom client secret (defaults to ANTIGRAVITY_CLIENT_SECRET)
|
||||
* @returns Token exchange result with new access token, or throws on error
|
||||
*/
|
||||
const MAX_REFRESH_RETRIES = 3
|
||||
const INITIAL_RETRY_DELAY_MS = 1000
|
||||
|
||||
function calculateRetryDelay(attempt: number): number {
|
||||
return Math.min(INITIAL_RETRY_DELAY_MS * Math.pow(2, attempt), 10000)
|
||||
}
|
||||
|
||||
function isRetryableError(status: number): boolean {
|
||||
if (status === 0) return true
|
||||
if (status === 429) return true
|
||||
if (status >= 500 && status < 600) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(
|
||||
refreshToken: string,
|
||||
clientId: string = ANTIGRAVITY_CLIENT_ID,
|
||||
@@ -52,35 +100,81 @@ export async function refreshAccessToken(
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
let lastError: AntigravityTokenRefreshError | undefined
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_REFRESH_RETRIES; attempt++) {
|
||||
try {
|
||||
const response = await fetch(GOOGLE_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: params,
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
refresh_token: data.refresh_token || refreshToken,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
const responseBody = await response.text().catch(() => undefined)
|
||||
const parsed = parseOAuthErrorPayload(responseBody)
|
||||
|
||||
lastError = new AntigravityTokenRefreshError({
|
||||
message: parsed.description || `Token refresh failed: ${response.status} ${response.statusText}`,
|
||||
code: parsed.code,
|
||||
description: parsed.description,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
responseBody,
|
||||
})
|
||||
|
||||
if (parsed.code === "invalid_grant") {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (!isRetryableError(response.status)) {
|
||||
throw lastError
|
||||
}
|
||||
|
||||
if (attempt < MAX_REFRESH_RETRIES) {
|
||||
const delay = calculateRetryDelay(attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof AntigravityTokenRefreshError) {
|
||||
throw error
|
||||
}
|
||||
|
||||
lastError = new AntigravityTokenRefreshError({
|
||||
message: error instanceof Error ? error.message : "Network error during token refresh",
|
||||
status: 0,
|
||||
statusText: "Network Error",
|
||||
})
|
||||
|
||||
if (attempt < MAX_REFRESH_RETRIES) {
|
||||
const delay = calculateRetryDelay(attempt)
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new AntigravityTokenRefreshError({
|
||||
message: "Token refresh failed after all retries",
|
||||
status: 0,
|
||||
statusText: "Max Retries Exceeded",
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "Unknown error")
|
||||
throw new Error(
|
||||
`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`
|
||||
)
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
expires_in: number
|
||||
token_type: string
|
||||
}
|
||||
|
||||
return {
|
||||
access_token: data.access_token,
|
||||
// Google may return a new refresh token, fall back to the original
|
||||
refresh_token: data.refresh_token || refreshToken,
|
||||
expires_in: data.expires_in,
|
||||
token_type: data.token_type,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,3 +194,20 @@ export interface AntigravityRefreshParts {
|
||||
projectId?: string
|
||||
managedProjectId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth error payload from Google
|
||||
* Google returns errors in multiple formats, this handles all of them
|
||||
*/
|
||||
export interface OAuthErrorPayload {
|
||||
error?: string | { status?: string; code?: string; message?: string }
|
||||
error_description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed OAuth error with normalized fields
|
||||
*/
|
||||
export interface ParsedOAuthError {
|
||||
code?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { parseJsonc } from "../shared"
|
||||
@@ -10,8 +10,53 @@ const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
|
||||
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
|
||||
|
||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
|
||||
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
||||
|
||||
interface NodeError extends Error {
|
||||
code?: string
|
||||
}
|
||||
|
||||
function isPermissionError(err: unknown): boolean {
|
||||
const nodeErr = err as NodeError
|
||||
return nodeErr?.code === "EACCES" || nodeErr?.code === "EPERM"
|
||||
}
|
||||
|
||||
function isFileNotFoundError(err: unknown): boolean {
|
||||
const nodeErr = err as NodeError
|
||||
return nodeErr?.code === "ENOENT"
|
||||
}
|
||||
|
||||
function formatErrorWithSuggestion(err: unknown, context: string): string {
|
||||
if (isPermissionError(err)) {
|
||||
return `Permission denied: Cannot ${context}. Try running with elevated permissions or check file ownership.`
|
||||
}
|
||||
|
||||
if (isFileNotFoundError(err)) {
|
||||
return `File not found while trying to ${context}. The file may have been deleted or moved.`
|
||||
}
|
||||
|
||||
if (err instanceof SyntaxError) {
|
||||
return `JSON syntax error while trying to ${context}: ${err.message}. Check for missing commas, brackets, or invalid characters.`
|
||||
}
|
||||
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
if (message.includes("ENOSPC")) {
|
||||
return `Disk full: Cannot ${context}. Free up disk space and try again.`
|
||||
}
|
||||
|
||||
if (message.includes("EROFS")) {
|
||||
return `Read-only filesystem: Cannot ${context}. Check if the filesystem is mounted read-only.`
|
||||
}
|
||||
|
||||
return `Failed to ${context}: ${message}`
|
||||
}
|
||||
|
||||
export async function fetchLatestVersion(packageName: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
|
||||
@@ -40,12 +85,46 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } {
|
||||
return { format: "none", path: OPENCODE_JSON }
|
||||
}
|
||||
|
||||
function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null {
|
||||
interface ParseConfigResult {
|
||||
config: OpenCodeConfig | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
function isEmptyOrWhitespace(content: string): boolean {
|
||||
return content.trim().length === 0
|
||||
}
|
||||
|
||||
function parseConfig(path: string, _isJsonc: boolean): OpenCodeConfig | null {
|
||||
const result = parseConfigWithError(path)
|
||||
return result.config
|
||||
}
|
||||
|
||||
function parseConfigWithError(path: string): ParseConfigResult {
|
||||
try {
|
||||
const stat = statSync(path)
|
||||
if (stat.size === 0) {
|
||||
return { config: null, error: `Config file is empty: ${path}. Delete it or add valid JSON content.` }
|
||||
}
|
||||
|
||||
const content = readFileSync(path, "utf-8")
|
||||
return parseJsonc<OpenCodeConfig>(content)
|
||||
} catch {
|
||||
return null
|
||||
|
||||
if (isEmptyOrWhitespace(content)) {
|
||||
return { config: null, error: `Config file contains only whitespace: ${path}. Delete it or add valid JSON content.` }
|
||||
}
|
||||
|
||||
const config = parseJsonc<OpenCodeConfig>(content)
|
||||
|
||||
if (config === null || config === undefined) {
|
||||
return { config: null, error: `Config file parsed to null/undefined: ${path}. Ensure it contains valid JSON.` }
|
||||
}
|
||||
|
||||
if (typeof config !== "object" || Array.isArray(config)) {
|
||||
return { config: null, error: `Config file must contain a JSON object, not ${Array.isArray(config) ? "an array" : typeof config}: ${path}` }
|
||||
}
|
||||
|
||||
return { config }
|
||||
} catch (err) {
|
||||
return { config: null, error: formatErrorWithSuggestion(err, `parse config file ${path}`) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,7 +135,11 @@ function ensureConfigDir(): void {
|
||||
}
|
||||
|
||||
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
ensureConfigDir()
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
const pluginName = "oh-my-opencode"
|
||||
@@ -68,11 +151,12 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
return { success: true, configPath: path }
|
||||
}
|
||||
|
||||
const config = parseConfig(path, format === "jsonc")
|
||||
if (!config) {
|
||||
return { success: false, configPath: path, error: "Failed to parse config" }
|
||||
const parseResult = parseConfigWithError(path)
|
||||
if (!parseResult.config) {
|
||||
return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" }
|
||||
}
|
||||
|
||||
const config = parseResult.config
|
||||
const plugins = config.plugin ?? []
|
||||
if (plugins.some((p) => p.startsWith(pluginName))) {
|
||||
return { success: true, configPath: path }
|
||||
@@ -102,7 +186,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
|
||||
return { success: true, configPath: path }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: path, error: String(err) }
|
||||
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,9 +230,16 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
|
||||
|
||||
if (!installConfig.hasClaude) {
|
||||
agents["Sisyphus"] = { model: "opencode/big-pickle" }
|
||||
}
|
||||
|
||||
if (installConfig.hasGemini) {
|
||||
agents["librarian"] = { model: "google/gemini-3-flash" }
|
||||
agents["explore"] = { model: "google/gemini-3-flash" }
|
||||
} else if (installConfig.hasClaude && installConfig.isMax20) {
|
||||
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else {
|
||||
agents["librarian"] = { model: "opencode/big-pickle" }
|
||||
} else if (!installConfig.isMax20) {
|
||||
agents["librarian"] = { model: "opencode/big-pickle" }
|
||||
agents["explore"] = { model: "opencode/big-pickle" }
|
||||
}
|
||||
|
||||
if (!installConfig.hasChatGPT) {
|
||||
@@ -176,60 +267,105 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
|
||||
}
|
||||
|
||||
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
|
||||
ensureConfigDir()
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
try {
|
||||
const newConfig = generateOmoConfig(installConfig)
|
||||
|
||||
if (existsSync(OMO_CONFIG)) {
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||
delete existing.agents
|
||||
const merged = deepMerge(existing, newConfig)
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
|
||||
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
}
|
||||
|
||||
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
}
|
||||
|
||||
delete existing.agents
|
||||
const merged = deepMerge(existing, newConfig)
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
}
|
||||
throw parseErr
|
||||
}
|
||||
} else {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
}
|
||||
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OMO_CONFIG, error: String(err) }
|
||||
return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeBinaryResult {
|
||||
binary: string
|
||||
version: string
|
||||
}
|
||||
|
||||
async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | null> {
|
||||
for (const binary of OPENCODE_BINARIES) {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { binary, version: output.trim() }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function isOpenCodeInstalled(): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn(["opencode", "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
return proc.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
const result = await findOpenCodeBinaryWithVersion()
|
||||
return result !== null
|
||||
}
|
||||
|
||||
export async function getOpenCodeVersion(): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn(["opencode", "--version"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
return proc.exitCode === 0 ? output.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const result = await findOpenCodeBinaryWithVersion()
|
||||
return result?.version ?? null
|
||||
}
|
||||
|
||||
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
|
||||
ensureConfigDir()
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
|
||||
try {
|
||||
const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null
|
||||
let existingConfig: OpenCodeConfig | null = null
|
||||
if (format !== "none") {
|
||||
const parseResult = parseConfigWithError(path)
|
||||
if (parseResult.error && !parseResult.config) {
|
||||
existingConfig = {}
|
||||
} else {
|
||||
existingConfig = parseResult.config
|
||||
}
|
||||
}
|
||||
|
||||
const plugins: string[] = existingConfig?.plugin ?? []
|
||||
|
||||
if (config.hasGemini) {
|
||||
@@ -250,18 +386,37 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: path }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: path, error: String(err) }
|
||||
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add auth plugins to config") }
|
||||
}
|
||||
}
|
||||
|
||||
export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
ensureConfigDir()
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
try {
|
||||
let packageJson: Record<string, unknown> = {}
|
||||
if (existsSync(OPENCODE_PACKAGE_JSON)) {
|
||||
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
|
||||
packageJson = JSON.parse(content)
|
||||
try {
|
||||
const stat = statSync(OPENCODE_PACKAGE_JSON)
|
||||
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
|
||||
|
||||
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
|
||||
packageJson = JSON.parse(content)
|
||||
if (typeof packageJson !== "object" || packageJson === null || Array.isArray(packageJson)) {
|
||||
packageJson = {}
|
||||
}
|
||||
}
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
packageJson = {}
|
||||
} else {
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
|
||||
@@ -271,21 +426,65 @@ export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: String(err) }
|
||||
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
}
|
||||
}
|
||||
|
||||
export interface BunInstallResult {
|
||||
success: boolean
|
||||
timedOut?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function runBunInstall(): Promise<boolean> {
|
||||
const result = await runBunInstallWithDetails()
|
||||
return result.success
|
||||
}
|
||||
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = Bun.spawn(["bun", "install"], {
|
||||
cwd: OPENCODE_CONFIG_DIR,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
await proc.exited
|
||||
return proc.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
|
||||
const timeoutPromise = new Promise<"timeout">((resolve) =>
|
||||
setTimeout(() => resolve("timeout"), BUN_INSTALL_TIMEOUT_MS)
|
||||
)
|
||||
|
||||
const exitPromise = proc.exited.then(() => "completed" as const)
|
||||
|
||||
const result = await Promise.race([exitPromise, timeoutPromise])
|
||||
|
||||
if (result === "timeout") {
|
||||
try {
|
||||
proc.kill()
|
||||
} catch {
|
||||
/* intentionally empty - process may have already exited */
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
timedOut: true,
|
||||
error: `bun install timed out after ${BUN_INSTALL_TIMEOUT_SECONDS} seconds. Try running manually: cd ~/.config/opencode && bun i`,
|
||||
}
|
||||
}
|
||||
|
||||
if (proc.exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
return {
|
||||
success: false,
|
||||
error: stderr.trim() || `bun install failed with exit code ${proc.exitCode}`,
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
return {
|
||||
success: false,
|
||||
error: `bun install failed: ${message}. Is bun installed? Try: curl -fsSL https://bun.sh/install | bash`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,11 +545,25 @@ const CODEX_PROVIDER_CONFIG = {
|
||||
}
|
||||
|
||||
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
ensureConfigDir()
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
|
||||
try {
|
||||
const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null
|
||||
let existingConfig: OpenCodeConfig | null = null
|
||||
if (format !== "none") {
|
||||
const parseResult = parseConfigWithError(path)
|
||||
if (parseResult.error && !parseResult.config) {
|
||||
existingConfig = {}
|
||||
} else {
|
||||
existingConfig = parseResult.config
|
||||
}
|
||||
}
|
||||
|
||||
const newConfig = { ...(existingConfig ?? {}) }
|
||||
|
||||
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
|
||||
@@ -370,7 +583,7 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: path }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: path, error: String(err) }
|
||||
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "add provider config") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,11 +606,12 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
return result
|
||||
}
|
||||
|
||||
const openCodeConfig = parseConfig(path, format === "jsonc")
|
||||
if (!openCodeConfig) {
|
||||
const parseResult = parseConfigWithError(path)
|
||||
if (!parseResult.config) {
|
||||
return result
|
||||
}
|
||||
|
||||
const openCodeConfig = parseResult.config
|
||||
const plugins = openCodeConfig.plugin ?? []
|
||||
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
|
||||
|
||||
@@ -413,8 +627,20 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
if (stat.size === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
if (isEmptyOrWhitespace(content)) {
|
||||
return result
|
||||
}
|
||||
|
||||
const omoConfig = parseJsonc<OmoConfigData>(content)
|
||||
if (!omoConfig || typeof omoConfig !== "object") {
|
||||
return result
|
||||
}
|
||||
|
||||
const agents = omoConfig.agents ?? {}
|
||||
|
||||
@@ -436,7 +662,7 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
|
||||
}
|
||||
} catch {
|
||||
/* intentionally empty - malformed config returns defaults */
|
||||
/* intentionally empty - malformed omo config returns defaults from opencode config detection */
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
114
src/cli/doctor/checks/auth.test.ts
Normal file
114
src/cli/doctor/checks/auth.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as auth from "./auth"
|
||||
|
||||
describe("auth check", () => {
|
||||
describe("getAuthProviderInfo", () => {
|
||||
it("returns anthropic as always available", () => {
|
||||
// #given anthropic provider
|
||||
// #when getting info
|
||||
const info = auth.getAuthProviderInfo("anthropic")
|
||||
|
||||
// #then should show plugin installed (builtin)
|
||||
expect(info.id).toBe("anthropic")
|
||||
expect(info.pluginInstalled).toBe(true)
|
||||
})
|
||||
|
||||
it("returns correct name for each provider", () => {
|
||||
// #given each provider
|
||||
// #when getting info
|
||||
// #then should have correct names
|
||||
expect(auth.getAuthProviderInfo("anthropic").name).toContain("Claude")
|
||||
expect(auth.getAuthProviderInfo("openai").name).toContain("ChatGPT")
|
||||
expect(auth.getAuthProviderInfo("google").name).toContain("Gemini")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkAuthProvider", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns pass when plugin installed", async () => {
|
||||
// #given plugin installed
|
||||
getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({
|
||||
id: "anthropic",
|
||||
name: "Anthropic (Claude)",
|
||||
pluginInstalled: true,
|
||||
configured: true,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await auth.checkAuthProvider("anthropic")
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
})
|
||||
|
||||
it("returns skip when plugin not installed", async () => {
|
||||
// #given plugin not installed
|
||||
getInfoSpy = spyOn(auth, "getAuthProviderInfo").mockReturnValue({
|
||||
id: "openai",
|
||||
name: "OpenAI (ChatGPT)",
|
||||
pluginInstalled: false,
|
||||
configured: false,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await auth.checkAuthProvider("openai")
|
||||
|
||||
// #then should skip
|
||||
expect(result.status).toBe("skip")
|
||||
expect(result.message).toContain("not installed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkAnthropicAuth", () => {
|
||||
it("returns a check result", async () => {
|
||||
// #given
|
||||
// #when checking anthropic
|
||||
const result = await auth.checkAnthropicAuth()
|
||||
|
||||
// #then should return valid result
|
||||
expect(result.name).toBeDefined()
|
||||
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkOpenAIAuth", () => {
|
||||
it("returns a check result", async () => {
|
||||
// #given
|
||||
// #when checking openai
|
||||
const result = await auth.checkOpenAIAuth()
|
||||
|
||||
// #then should return valid result
|
||||
expect(result.name).toBeDefined()
|
||||
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkGoogleAuth", () => {
|
||||
it("returns a check result", async () => {
|
||||
// #given
|
||||
// #when checking google
|
||||
const result = await auth.checkGoogleAuth()
|
||||
|
||||
// #then should return valid result
|
||||
expect(result.name).toBeDefined()
|
||||
expect(["pass", "fail", "warn", "skip"]).toContain(result.status)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getAuthCheckDefinitions", () => {
|
||||
it("returns definitions for all three providers", () => {
|
||||
// #given
|
||||
// #when getting definitions
|
||||
const defs = auth.getAuthCheckDefinitions()
|
||||
|
||||
// #then should have 3 definitions
|
||||
expect(defs.length).toBe(3)
|
||||
expect(defs.every((d) => d.category === "authentication")).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
115
src/cli/doctor/checks/auth.ts
Normal file
115
src/cli/doctor/checks/auth.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
|
||||
const AUTH_PLUGINS: Record<AuthProviderId, { plugin: string; name: string }> = {
|
||||
anthropic: { plugin: "builtin", name: "Anthropic (Claude)" },
|
||||
openai: { plugin: "opencode-openai-codex-auth", name: "OpenAI (ChatGPT)" },
|
||||
google: { plugin: "opencode-antigravity-auth", name: "Google (Gemini)" },
|
||||
}
|
||||
|
||||
function getOpenCodeConfig(): { plugin?: string[] } | null {
|
||||
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
|
||||
if (!existsSync(configPath)) return null
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
return parseJsonc<{ plugin?: string[] }>(content)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isPluginInstalled(plugins: string[], pluginName: string): boolean {
|
||||
if (pluginName === "builtin") return true
|
||||
return plugins.some((p) => p === pluginName || p.startsWith(`${pluginName}@`))
|
||||
}
|
||||
|
||||
export function getAuthProviderInfo(providerId: AuthProviderId): AuthProviderInfo {
|
||||
const config = getOpenCodeConfig()
|
||||
const plugins = config?.plugin ?? []
|
||||
const authConfig = AUTH_PLUGINS[providerId]
|
||||
|
||||
const pluginInstalled = isPluginInstalled(plugins, authConfig.plugin)
|
||||
|
||||
return {
|
||||
id: providerId,
|
||||
name: authConfig.name,
|
||||
pluginInstalled,
|
||||
configured: pluginInstalled,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAuthProvider(providerId: AuthProviderId): Promise<CheckResult> {
|
||||
const info = getAuthProviderInfo(providerId)
|
||||
const checkId = `auth-${providerId}` as keyof typeof CHECK_NAMES
|
||||
const checkName = CHECK_NAMES[checkId] || info.name
|
||||
|
||||
if (!info.pluginInstalled) {
|
||||
return {
|
||||
name: checkName,
|
||||
status: "skip",
|
||||
message: "Auth plugin not installed",
|
||||
details: [
|
||||
`Plugin: ${AUTH_PLUGINS[providerId].plugin}`,
|
||||
"Run: bunx oh-my-opencode install",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: checkName,
|
||||
status: "pass",
|
||||
message: "Auth plugin available",
|
||||
details: [
|
||||
providerId === "anthropic"
|
||||
? "Run: opencode auth login (select Anthropic)"
|
||||
: `Plugin: ${AUTH_PLUGINS[providerId].plugin}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkAnthropicAuth(): Promise<CheckResult> {
|
||||
return checkAuthProvider("anthropic")
|
||||
}
|
||||
|
||||
export async function checkOpenAIAuth(): Promise<CheckResult> {
|
||||
return checkAuthProvider("openai")
|
||||
}
|
||||
|
||||
export async function checkGoogleAuth(): Promise<CheckResult> {
|
||||
return checkAuthProvider("google")
|
||||
}
|
||||
|
||||
export function getAuthCheckDefinitions(): CheckDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: CHECK_IDS.AUTH_ANTHROPIC,
|
||||
name: CHECK_NAMES[CHECK_IDS.AUTH_ANTHROPIC],
|
||||
category: "authentication",
|
||||
check: checkAnthropicAuth,
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.AUTH_OPENAI,
|
||||
name: CHECK_NAMES[CHECK_IDS.AUTH_OPENAI],
|
||||
category: "authentication",
|
||||
check: checkOpenAIAuth,
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.AUTH_GOOGLE,
|
||||
name: CHECK_NAMES[CHECK_IDS.AUTH_GOOGLE],
|
||||
category: "authentication",
|
||||
check: checkGoogleAuth,
|
||||
critical: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
103
src/cli/doctor/checks/config.test.ts
Normal file
103
src/cli/doctor/checks/config.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as config from "./config"
|
||||
|
||||
describe("config check", () => {
|
||||
describe("validateConfig", () => {
|
||||
it("returns valid: false for non-existent file", () => {
|
||||
// #given non-existent file path
|
||||
// #when validating
|
||||
const result = config.validateConfig("/non/existent/path.json")
|
||||
|
||||
// #then should indicate invalid
|
||||
expect(result.valid).toBe(false)
|
||||
expect(result.errors.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConfigInfo", () => {
|
||||
it("returns exists: false when no config found", () => {
|
||||
// #given no config file exists
|
||||
// #when getting config info
|
||||
const info = config.getConfigInfo()
|
||||
|
||||
// #then should handle gracefully
|
||||
expect(typeof info.exists).toBe("boolean")
|
||||
expect(typeof info.valid).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkConfigValidity", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns pass when no config exists (uses defaults)", async () => {
|
||||
// #given no config file
|
||||
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
|
||||
exists: false,
|
||||
path: null,
|
||||
format: null,
|
||||
valid: true,
|
||||
errors: [],
|
||||
})
|
||||
|
||||
// #when checking validity
|
||||
const result = await config.checkConfigValidity()
|
||||
|
||||
// #then should pass with default message
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("default")
|
||||
})
|
||||
|
||||
it("returns pass when config is valid", async () => {
|
||||
// #given valid config
|
||||
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
|
||||
exists: true,
|
||||
path: "/home/user/.config/opencode/oh-my-opencode.json",
|
||||
format: "json",
|
||||
valid: true,
|
||||
errors: [],
|
||||
})
|
||||
|
||||
// #when checking validity
|
||||
const result = await config.checkConfigValidity()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("JSON")
|
||||
})
|
||||
|
||||
it("returns fail when config has validation errors", async () => {
|
||||
// #given invalid config
|
||||
getInfoSpy = spyOn(config, "getConfigInfo").mockReturnValue({
|
||||
exists: true,
|
||||
path: "/home/user/.config/opencode/oh-my-opencode.json",
|
||||
format: "json",
|
||||
valid: false,
|
||||
errors: ["agents.oracle: Invalid model format"],
|
||||
})
|
||||
|
||||
// #when checking validity
|
||||
const result = await config.checkConfigValidity()
|
||||
|
||||
// #then should fail with errors
|
||||
expect(result.status).toBe("fail")
|
||||
expect(result.details?.some((d) => d.includes("Error"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getConfigCheckDefinition", () => {
|
||||
it("returns valid check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = config.getConfigCheckDefinition()
|
||||
|
||||
// #then should have required properties
|
||||
expect(def.id).toBe("config-validation")
|
||||
expect(def.category).toBe("configuration")
|
||||
expect(def.critical).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
123
src/cli/doctor/checks/config.ts
Normal file
123
src/cli/doctor/checks/config.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc, detectConfigFile } from "../../../shared"
|
||||
import { OhMyOpenCodeConfigSchema } from "../../../config"
|
||||
|
||||
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`)
|
||||
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
|
||||
|
||||
function findConfigPath(): { path: string; format: "json" | "jsonc" } | null {
|
||||
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
|
||||
if (projectDetected.format !== "none") {
|
||||
return { path: projectDetected.path, format: projectDetected.format as "json" | "jsonc" }
|
||||
}
|
||||
|
||||
const userDetected = detectConfigFile(USER_CONFIG_BASE)
|
||||
if (userDetected.format !== "none") {
|
||||
return { path: userDetected.path, format: userDetected.format as "json" | "jsonc" }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function validateConfig(configPath: string): { valid: boolean; errors: string[] } {
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const rawConfig = parseJsonc<Record<string, unknown>>(content)
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
|
||||
if (!result.success) {
|
||||
const errors = result.error.issues.map(
|
||||
(i) => `${i.path.join(".")}: ${i.message}`
|
||||
)
|
||||
return { valid: false, errors }
|
||||
}
|
||||
|
||||
return { valid: true, errors: [] }
|
||||
} catch (err) {
|
||||
return {
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : "Failed to parse config"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigInfo(): ConfigInfo {
|
||||
const configPath = findConfigPath()
|
||||
|
||||
if (!configPath) {
|
||||
return {
|
||||
exists: false,
|
||||
path: null,
|
||||
format: null,
|
||||
valid: true,
|
||||
errors: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(configPath.path)) {
|
||||
return {
|
||||
exists: false,
|
||||
path: configPath.path,
|
||||
format: configPath.format,
|
||||
valid: true,
|
||||
errors: [],
|
||||
}
|
||||
}
|
||||
|
||||
const validation = validateConfig(configPath.path)
|
||||
|
||||
return {
|
||||
exists: true,
|
||||
path: configPath.path,
|
||||
format: configPath.format,
|
||||
valid: validation.valid,
|
||||
errors: validation.errors,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkConfigValidity(): Promise<CheckResult> {
|
||||
const info = getConfigInfo()
|
||||
|
||||
if (!info.exists) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
status: "pass",
|
||||
message: "Using default configuration",
|
||||
details: ["No custom config file found (optional)"],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.valid) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
status: "fail",
|
||||
message: "Configuration has validation errors",
|
||||
details: [
|
||||
`Path: ${info.path}`,
|
||||
...info.errors.map((e) => `Error: ${e}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
status: "pass",
|
||||
message: `Valid ${info.format?.toUpperCase()} config`,
|
||||
details: [`Path: ${info.path}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.CONFIG_VALIDATION,
|
||||
name: CHECK_NAMES[CHECK_IDS.CONFIG_VALIDATION],
|
||||
category: "configuration",
|
||||
check: checkConfigValidity,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
152
src/cli/doctor/checks/dependencies.test.ts
Normal file
152
src/cli/doctor/checks/dependencies.test.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as deps from "./dependencies"
|
||||
|
||||
describe("dependencies check", () => {
|
||||
describe("checkAstGrepCli", () => {
|
||||
it("returns dependency info", async () => {
|
||||
// #given
|
||||
// #when checking ast-grep cli
|
||||
const info = await deps.checkAstGrepCli()
|
||||
|
||||
// #then should return valid info
|
||||
expect(info.name).toBe("AST-Grep CLI")
|
||||
expect(info.required).toBe(false)
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkAstGrepNapi", () => {
|
||||
it("returns dependency info", () => {
|
||||
// #given
|
||||
// #when checking ast-grep napi
|
||||
const info = deps.checkAstGrepNapi()
|
||||
|
||||
// #then should return valid info
|
||||
expect(info.name).toBe("AST-Grep NAPI")
|
||||
expect(info.required).toBe(false)
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkCommentChecker", () => {
|
||||
it("returns dependency info", async () => {
|
||||
// #given
|
||||
// #when checking comment checker
|
||||
const info = await deps.checkCommentChecker()
|
||||
|
||||
// #then should return valid info
|
||||
expect(info.name).toBe("Comment Checker")
|
||||
expect(info.required).toBe(false)
|
||||
expect(typeof info.installed).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkDependencyAstGrepCli", () => {
|
||||
let checkSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
checkSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns pass when installed", async () => {
|
||||
// #given ast-grep installed
|
||||
checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({
|
||||
name: "AST-Grep CLI",
|
||||
required: false,
|
||||
installed: true,
|
||||
version: "0.25.0",
|
||||
path: "/usr/local/bin/sg",
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await deps.checkDependencyAstGrepCli()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("0.25.0")
|
||||
})
|
||||
|
||||
it("returns warn when not installed", async () => {
|
||||
// #given ast-grep not installed
|
||||
checkSpy = spyOn(deps, "checkAstGrepCli").mockResolvedValue({
|
||||
name: "AST-Grep CLI",
|
||||
required: false,
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
installHint: "Install: npm install -g @ast-grep/cli",
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await deps.checkDependencyAstGrepCli()
|
||||
|
||||
// #then should warn (optional)
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("optional")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkDependencyAstGrepNapi", () => {
|
||||
let checkSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
checkSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns pass when installed", async () => {
|
||||
// #given napi installed
|
||||
checkSpy = spyOn(deps, "checkAstGrepNapi").mockReturnValue({
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await deps.checkDependencyAstGrepNapi()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkDependencyCommentChecker", () => {
|
||||
let checkSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
checkSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns warn when not installed", async () => {
|
||||
// #given comment checker not installed
|
||||
checkSpy = spyOn(deps, "checkCommentChecker").mockResolvedValue({
|
||||
name: "Comment Checker",
|
||||
required: false,
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
installHint: "Hook will be disabled if not available",
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await deps.checkDependencyCommentChecker()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getDependencyCheckDefinitions", () => {
|
||||
it("returns definitions for all dependencies", () => {
|
||||
// #given
|
||||
// #when getting definitions
|
||||
const defs = deps.getDependencyCheckDefinitions()
|
||||
|
||||
// #then should have 3 definitions
|
||||
expect(defs.length).toBe(3)
|
||||
expect(defs.every((d) => d.category === "dependencies")).toBe(true)
|
||||
expect(defs.every((d) => d.critical === false)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
163
src/cli/doctor/checks/dependencies.ts
Normal file
163
src/cli/doctor/checks/dependencies.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { CheckResult, CheckDefinition, DependencyInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
|
||||
try {
|
||||
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { exists: true, path: output.trim() }
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - binary not found
|
||||
}
|
||||
return { exists: false, path: null }
|
||||
}
|
||||
|
||||
async function getBinaryVersion(binary: string): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return output.trim().split("\n")[0]
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - version unavailable
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function checkAstGrepCli(): Promise<DependencyInfo> {
|
||||
const binaryCheck = await checkBinaryExists("sg")
|
||||
const altBinaryCheck = !binaryCheck.exists ? await checkBinaryExists("ast-grep") : null
|
||||
|
||||
const binary = binaryCheck.exists ? binaryCheck : altBinaryCheck
|
||||
if (!binary || !binary.exists) {
|
||||
return {
|
||||
name: "AST-Grep CLI",
|
||||
required: false,
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
installHint: "Install: npm install -g @ast-grep/cli",
|
||||
}
|
||||
}
|
||||
|
||||
const version = await getBinaryVersion(binary.path!)
|
||||
|
||||
return {
|
||||
name: "AST-Grep CLI",
|
||||
required: false,
|
||||
installed: true,
|
||||
version,
|
||||
path: binary.path,
|
||||
}
|
||||
}
|
||||
|
||||
export function checkAstGrepNapi(): DependencyInfo {
|
||||
try {
|
||||
require.resolve("@ast-grep/napi")
|
||||
return {
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
installed: true,
|
||||
version: null,
|
||||
path: null,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
name: "AST-Grep NAPI",
|
||||
required: false,
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
installHint: "Will use CLI fallback if available",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkCommentChecker(): Promise<DependencyInfo> {
|
||||
const binaryCheck = await checkBinaryExists("comment-checker")
|
||||
|
||||
if (!binaryCheck.exists) {
|
||||
return {
|
||||
name: "Comment Checker",
|
||||
required: false,
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
installHint: "Hook will be disabled if not available",
|
||||
}
|
||||
}
|
||||
|
||||
const version = await getBinaryVersion("comment-checker")
|
||||
|
||||
return {
|
||||
name: "Comment Checker",
|
||||
required: false,
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryCheck.path,
|
||||
}
|
||||
}
|
||||
|
||||
function dependencyToCheckResult(dep: DependencyInfo, checkName: string): CheckResult {
|
||||
if (dep.installed) {
|
||||
return {
|
||||
name: checkName,
|
||||
status: "pass",
|
||||
message: dep.version ?? "installed",
|
||||
details: dep.path ? [`Path: ${dep.path}`] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: checkName,
|
||||
status: "warn",
|
||||
message: "Not installed (optional)",
|
||||
details: dep.installHint ? [dep.installHint] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkDependencyAstGrepCli(): Promise<CheckResult> {
|
||||
const info = await checkAstGrepCli()
|
||||
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI])
|
||||
}
|
||||
|
||||
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
|
||||
const info = checkAstGrepNapi()
|
||||
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
|
||||
}
|
||||
|
||||
export async function checkDependencyCommentChecker(): Promise<CheckResult> {
|
||||
const info = await checkCommentChecker()
|
||||
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER])
|
||||
}
|
||||
|
||||
export function getDependencyCheckDefinitions(): CheckDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: CHECK_IDS.DEP_AST_GREP_CLI,
|
||||
name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_CLI],
|
||||
category: "dependencies",
|
||||
check: checkDependencyAstGrepCli,
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.DEP_AST_GREP_NAPI,
|
||||
name: CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI],
|
||||
category: "dependencies",
|
||||
check: checkDependencyAstGrepNapi,
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.DEP_COMMENT_CHECKER,
|
||||
name: CHECK_NAMES[CHECK_IDS.DEP_COMMENT_CHECKER],
|
||||
category: "dependencies",
|
||||
check: checkDependencyCommentChecker,
|
||||
critical: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
31
src/cli/doctor/checks/index.ts
Normal file
31
src/cli/doctor/checks/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { CheckDefinition } from "../types"
|
||||
import { getOpenCodeCheckDefinition } from "./opencode"
|
||||
import { getPluginCheckDefinition } from "./plugin"
|
||||
import { getConfigCheckDefinition } from "./config"
|
||||
import { getAuthCheckDefinitions } from "./auth"
|
||||
import { getDependencyCheckDefinitions } from "./dependencies"
|
||||
import { getLspCheckDefinition } from "./lsp"
|
||||
import { getMcpCheckDefinitions } from "./mcp"
|
||||
import { getVersionCheckDefinition } from "./version"
|
||||
|
||||
export * from "./opencode"
|
||||
export * from "./plugin"
|
||||
export * from "./config"
|
||||
export * from "./auth"
|
||||
export * from "./dependencies"
|
||||
export * from "./lsp"
|
||||
export * from "./mcp"
|
||||
export * from "./version"
|
||||
|
||||
export function getAllCheckDefinitions(): CheckDefinition[] {
|
||||
return [
|
||||
getOpenCodeCheckDefinition(),
|
||||
getPluginCheckDefinition(),
|
||||
getConfigCheckDefinition(),
|
||||
...getAuthCheckDefinitions(),
|
||||
...getDependencyCheckDefinitions(),
|
||||
getLspCheckDefinition(),
|
||||
...getMcpCheckDefinitions(),
|
||||
getVersionCheckDefinition(),
|
||||
]
|
||||
}
|
||||
117
src/cli/doctor/checks/lsp.test.ts
Normal file
117
src/cli/doctor/checks/lsp.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as lsp from "./lsp"
|
||||
import type { LspServerInfo } from "../types"
|
||||
|
||||
describe("lsp check", () => {
|
||||
describe("getLspServersInfo", () => {
|
||||
it("returns array of server info", async () => {
|
||||
// #given
|
||||
// #when getting servers info
|
||||
const servers = await lsp.getLspServersInfo()
|
||||
|
||||
// #then should return array with expected structure
|
||||
expect(Array.isArray(servers)).toBe(true)
|
||||
servers.forEach((s) => {
|
||||
expect(s.id).toBeDefined()
|
||||
expect(typeof s.installed).toBe("boolean")
|
||||
expect(Array.isArray(s.extensions)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLspServerStats", () => {
|
||||
it("counts installed servers correctly", () => {
|
||||
// #given servers with mixed installation status
|
||||
const servers = [
|
||||
{ id: "ts", installed: true, extensions: [".ts"], source: "builtin" as const },
|
||||
{ id: "py", installed: false, extensions: [".py"], source: "builtin" as const },
|
||||
{ id: "go", installed: true, extensions: [".go"], source: "builtin" as const },
|
||||
]
|
||||
|
||||
// #when getting stats
|
||||
const stats = lsp.getLspServerStats(servers)
|
||||
|
||||
// #then should count correctly
|
||||
expect(stats.installed).toBe(2)
|
||||
expect(stats.total).toBe(3)
|
||||
})
|
||||
|
||||
it("handles empty array", () => {
|
||||
// #given no servers
|
||||
const servers: LspServerInfo[] = []
|
||||
|
||||
// #when getting stats
|
||||
const stats = lsp.getLspServerStats(servers)
|
||||
|
||||
// #then should return zeros
|
||||
expect(stats.installed).toBe(0)
|
||||
expect(stats.total).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkLspServers", () => {
|
||||
let getServersSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getServersSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns warn when no servers installed", async () => {
|
||||
// #given no servers installed
|
||||
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
|
||||
{ id: "typescript-language-server", installed: false, extensions: [".ts"], source: "builtin" },
|
||||
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
|
||||
])
|
||||
|
||||
// #when checking
|
||||
const result = await lsp.checkLspServers()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("No LSP servers")
|
||||
})
|
||||
|
||||
it("returns pass when servers installed", async () => {
|
||||
// #given some servers installed
|
||||
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
|
||||
{ id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" },
|
||||
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
|
||||
])
|
||||
|
||||
// #when checking
|
||||
const result = await lsp.checkLspServers()
|
||||
|
||||
// #then should pass with count
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("1/2")
|
||||
})
|
||||
|
||||
it("lists installed and missing servers in details", async () => {
|
||||
// #given mixed installation
|
||||
getServersSpy = spyOn(lsp, "getLspServersInfo").mockResolvedValue([
|
||||
{ id: "typescript-language-server", installed: true, extensions: [".ts"], source: "builtin" },
|
||||
{ id: "pyright", installed: false, extensions: [".py"], source: "builtin" },
|
||||
])
|
||||
|
||||
// #when checking
|
||||
const result = await lsp.checkLspServers()
|
||||
|
||||
// #then should list both
|
||||
expect(result.details?.some((d) => d.includes("Installed"))).toBe(true)
|
||||
expect(result.details?.some((d) => d.includes("Not found"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getLspCheckDefinition", () => {
|
||||
it("returns valid check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = lsp.getLspCheckDefinition()
|
||||
|
||||
// #then should have required properties
|
||||
expect(def.id).toBe("lsp-servers")
|
||||
expect(def.category).toBe("tools")
|
||||
expect(def.critical).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
85
src/cli/doctor/checks/lsp.ts
Normal file
85
src/cli/doctor/checks/lsp.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { CheckResult, CheckDefinition, LspServerInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
|
||||
const DEFAULT_LSP_SERVERS: Array<{
|
||||
id: string
|
||||
binary: string
|
||||
extensions: string[]
|
||||
}> = [
|
||||
{ id: "typescript-language-server", binary: "typescript-language-server", extensions: [".ts", ".tsx", ".js", ".jsx"] },
|
||||
{ id: "pyright", binary: "pyright-langserver", extensions: [".py"] },
|
||||
{ id: "rust-analyzer", binary: "rust-analyzer", extensions: [".rs"] },
|
||||
{ id: "gopls", binary: "gopls", extensions: [".go"] },
|
||||
]
|
||||
|
||||
async function checkBinaryExists(binary: string): Promise<boolean> {
|
||||
try {
|
||||
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
|
||||
await proc.exited
|
||||
return proc.exitCode === 0
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLspServersInfo(): Promise<LspServerInfo[]> {
|
||||
const servers: LspServerInfo[] = []
|
||||
|
||||
for (const server of DEFAULT_LSP_SERVERS) {
|
||||
const installed = await checkBinaryExists(server.binary)
|
||||
servers.push({
|
||||
id: server.id,
|
||||
installed,
|
||||
extensions: server.extensions,
|
||||
source: "builtin",
|
||||
})
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
export function getLspServerStats(servers: LspServerInfo[]): { installed: number; total: number } {
|
||||
const installed = servers.filter((s) => s.installed).length
|
||||
return { installed, total: servers.length }
|
||||
}
|
||||
|
||||
export async function checkLspServers(): Promise<CheckResult> {
|
||||
const servers = await getLspServersInfo()
|
||||
const stats = getLspServerStats(servers)
|
||||
const installedServers = servers.filter((s) => s.installed)
|
||||
const missingServers = servers.filter((s) => !s.installed)
|
||||
|
||||
if (stats.installed === 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
|
||||
status: "warn",
|
||||
message: "No LSP servers detected",
|
||||
details: [
|
||||
"LSP tools will have limited functionality",
|
||||
...missingServers.map((s) => `Missing: ${s.id}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const details = [
|
||||
...installedServers.map((s) => `Installed: ${s.id}`),
|
||||
...missingServers.map((s) => `Not found: ${s.id} (optional)`),
|
||||
]
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
|
||||
status: "pass",
|
||||
message: `${stats.installed}/${stats.total} servers available`,
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
export function getLspCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.LSP_SERVERS,
|
||||
name: CHECK_NAMES[CHECK_IDS.LSP_SERVERS],
|
||||
category: "tools",
|
||||
check: checkLspServers,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
117
src/cli/doctor/checks/mcp.test.ts
Normal file
117
src/cli/doctor/checks/mcp.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as mcp from "./mcp"
|
||||
|
||||
describe("mcp check", () => {
|
||||
describe("getBuiltinMcpInfo", () => {
|
||||
it("returns builtin servers", () => {
|
||||
// #given
|
||||
// #when getting builtin info
|
||||
const servers = mcp.getBuiltinMcpInfo()
|
||||
|
||||
// #then should include expected servers
|
||||
expect(servers.length).toBe(3)
|
||||
expect(servers.every((s) => s.type === "builtin")).toBe(true)
|
||||
expect(servers.every((s) => s.enabled === true)).toBe(true)
|
||||
expect(servers.map((s) => s.id)).toContain("context7")
|
||||
expect(servers.map((s) => s.id)).toContain("websearch_exa")
|
||||
expect(servers.map((s) => s.id)).toContain("grep_app")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUserMcpInfo", () => {
|
||||
it("returns empty array when no user config", () => {
|
||||
// #given no user config exists
|
||||
// #when getting user info
|
||||
const servers = mcp.getUserMcpInfo()
|
||||
|
||||
// #then should return array (may be empty)
|
||||
expect(Array.isArray(servers)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkBuiltinMcpServers", () => {
|
||||
it("returns pass with server count", async () => {
|
||||
// #given
|
||||
// #when checking builtin servers
|
||||
const result = await mcp.checkBuiltinMcpServers()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("3")
|
||||
expect(result.message).toContain("enabled")
|
||||
})
|
||||
|
||||
it("lists enabled servers in details", async () => {
|
||||
// #given
|
||||
// #when checking builtin servers
|
||||
const result = await mcp.checkBuiltinMcpServers()
|
||||
|
||||
// #then should list servers
|
||||
expect(result.details?.some((d) => d.includes("context7"))).toBe(true)
|
||||
expect(result.details?.some((d) => d.includes("websearch_exa"))).toBe(true)
|
||||
expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkUserMcpServers", () => {
|
||||
let getUserSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getUserSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns skip when no user config", async () => {
|
||||
// #given no user servers
|
||||
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([])
|
||||
|
||||
// #when checking
|
||||
const result = await mcp.checkUserMcpServers()
|
||||
|
||||
// #then should skip
|
||||
expect(result.status).toBe("skip")
|
||||
expect(result.message).toContain("No user MCP")
|
||||
})
|
||||
|
||||
it("returns pass when valid user servers", async () => {
|
||||
// #given valid user servers
|
||||
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
|
||||
{ id: "custom-mcp", type: "user", enabled: true, valid: true },
|
||||
])
|
||||
|
||||
// #when checking
|
||||
const result = await mcp.checkUserMcpServers()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("1")
|
||||
})
|
||||
|
||||
it("returns warn when servers have issues", async () => {
|
||||
// #given invalid server config
|
||||
getUserSpy = spyOn(mcp, "getUserMcpInfo").mockReturnValue([
|
||||
{ id: "bad-mcp", type: "user", enabled: true, valid: false, error: "Missing command" },
|
||||
])
|
||||
|
||||
// #when checking
|
||||
const result = await mcp.checkUserMcpServers()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.details?.some((d) => d.includes("Invalid"))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getMcpCheckDefinitions", () => {
|
||||
it("returns definitions for builtin and user", () => {
|
||||
// #given
|
||||
// #when getting definitions
|
||||
const defs = mcp.getMcpCheckDefinitions()
|
||||
|
||||
// #then should have 2 definitions
|
||||
expect(defs.length).toBe(2)
|
||||
expect(defs.every((d) => d.category === "tools")).toBe(true)
|
||||
expect(defs.map((d) => d.id)).toContain("mcp-builtin")
|
||||
expect(defs.map((d) => d.id)).toContain("mcp-user")
|
||||
})
|
||||
})
|
||||
})
|
||||
128
src/cli/doctor/checks/mcp.ts
Normal file
128
src/cli/doctor/checks/mcp.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, McpServerInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const BUILTIN_MCP_SERVERS = ["context7", "websearch_exa", "grep_app"]
|
||||
|
||||
const MCP_CONFIG_PATHS = [
|
||||
join(homedir(), ".claude", ".mcp.json"),
|
||||
join(process.cwd(), ".mcp.json"),
|
||||
join(process.cwd(), ".claude", ".mcp.json"),
|
||||
]
|
||||
|
||||
interface McpConfig {
|
||||
mcpServers?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function loadUserMcpConfig(): Record<string, unknown> {
|
||||
const servers: Record<string, unknown> = {}
|
||||
|
||||
for (const configPath of MCP_CONFIG_PATHS) {
|
||||
if (!existsSync(configPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const config = parseJsonc<McpConfig>(content)
|
||||
if (config.mcpServers) {
|
||||
Object.assign(servers, config.mcpServers)
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - skip invalid configs
|
||||
}
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
export function getBuiltinMcpInfo(): McpServerInfo[] {
|
||||
return BUILTIN_MCP_SERVERS.map((id) => ({
|
||||
id,
|
||||
type: "builtin" as const,
|
||||
enabled: true,
|
||||
valid: true,
|
||||
}))
|
||||
}
|
||||
|
||||
export function getUserMcpInfo(): McpServerInfo[] {
|
||||
const userServers = loadUserMcpConfig()
|
||||
const servers: McpServerInfo[] = []
|
||||
|
||||
for (const [id, config] of Object.entries(userServers)) {
|
||||
const isValid = typeof config === "object" && config !== null
|
||||
servers.push({
|
||||
id,
|
||||
type: "user",
|
||||
enabled: true,
|
||||
valid: isValid,
|
||||
error: isValid ? undefined : "Invalid configuration format",
|
||||
})
|
||||
}
|
||||
|
||||
return servers
|
||||
}
|
||||
|
||||
export async function checkBuiltinMcpServers(): Promise<CheckResult> {
|
||||
const servers = getBuiltinMcpInfo()
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
|
||||
status: "pass",
|
||||
message: `${servers.length} built-in servers enabled`,
|
||||
details: servers.map((s) => `Enabled: ${s.id}`),
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkUserMcpServers(): Promise<CheckResult> {
|
||||
const servers = getUserMcpInfo()
|
||||
|
||||
if (servers.length === 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
|
||||
status: "skip",
|
||||
message: "No user MCP configuration found",
|
||||
details: ["Optional: Add .mcp.json for custom MCP servers"],
|
||||
}
|
||||
}
|
||||
|
||||
const invalidServers = servers.filter((s) => !s.valid)
|
||||
if (invalidServers.length > 0) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
|
||||
status: "warn",
|
||||
message: `${invalidServers.length} server(s) have configuration issues`,
|
||||
details: [
|
||||
...servers.filter((s) => s.valid).map((s) => `Valid: ${s.id}`),
|
||||
...invalidServers.map((s) => `Invalid: ${s.id} - ${s.error}`),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
|
||||
status: "pass",
|
||||
message: `${servers.length} user server(s) configured`,
|
||||
details: servers.map((s) => `Configured: ${s.id}`),
|
||||
}
|
||||
}
|
||||
|
||||
export function getMcpCheckDefinitions(): CheckDefinition[] {
|
||||
return [
|
||||
{
|
||||
id: CHECK_IDS.MCP_BUILTIN,
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_BUILTIN],
|
||||
category: "tools",
|
||||
check: checkBuiltinMcpServers,
|
||||
critical: false,
|
||||
},
|
||||
{
|
||||
id: CHECK_IDS.MCP_USER,
|
||||
name: CHECK_NAMES[CHECK_IDS.MCP_USER],
|
||||
category: "tools",
|
||||
check: checkUserMcpServers,
|
||||
critical: false,
|
||||
},
|
||||
]
|
||||
}
|
||||
139
src/cli/doctor/checks/opencode.test.ts
Normal file
139
src/cli/doctor/checks/opencode.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, spyOn, beforeEach, afterEach } from "bun:test"
|
||||
import * as opencode from "./opencode"
|
||||
import { MIN_OPENCODE_VERSION } from "../constants"
|
||||
|
||||
describe("opencode check", () => {
|
||||
describe("compareVersions", () => {
|
||||
it("returns true when current >= minimum", () => {
|
||||
// #given versions where current is greater
|
||||
// #when comparing
|
||||
// #then should return true
|
||||
expect(opencode.compareVersions("1.0.200", "1.0.150")).toBe(true)
|
||||
expect(opencode.compareVersions("1.1.0", "1.0.150")).toBe(true)
|
||||
expect(opencode.compareVersions("2.0.0", "1.0.150")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true when versions are equal", () => {
|
||||
// #given equal versions
|
||||
// #when comparing
|
||||
// #then should return true
|
||||
expect(opencode.compareVersions("1.0.150", "1.0.150")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when current < minimum", () => {
|
||||
// #given version below minimum
|
||||
// #when comparing
|
||||
// #then should return false
|
||||
expect(opencode.compareVersions("1.0.100", "1.0.150")).toBe(false)
|
||||
expect(opencode.compareVersions("0.9.0", "1.0.150")).toBe(false)
|
||||
})
|
||||
|
||||
it("handles version prefixes", () => {
|
||||
// #given version with v prefix
|
||||
// #when comparing
|
||||
// #then should strip prefix and compare correctly
|
||||
expect(opencode.compareVersions("v1.0.200", "1.0.150")).toBe(true)
|
||||
})
|
||||
|
||||
it("handles prerelease versions", () => {
|
||||
// #given prerelease version
|
||||
// #when comparing
|
||||
// #then should use base version
|
||||
expect(opencode.compareVersions("1.0.200-beta.1", "1.0.150")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeInfo", () => {
|
||||
it("returns installed: false when binary not found", async () => {
|
||||
// #given no opencode binary
|
||||
const spy = spyOn(opencode, "findOpenCodeBinary").mockResolvedValue(null)
|
||||
|
||||
// #when getting info
|
||||
const info = await opencode.getOpenCodeInfo()
|
||||
|
||||
// #then should indicate not installed
|
||||
expect(info.installed).toBe(false)
|
||||
expect(info.version).toBeNull()
|
||||
expect(info.path).toBeNull()
|
||||
expect(info.binary).toBeNull()
|
||||
|
||||
spy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkOpenCodeInstallation", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns fail when not installed", async () => {
|
||||
// #given opencode not installed
|
||||
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
binary: null,
|
||||
})
|
||||
|
||||
// #when checking installation
|
||||
const result = await opencode.checkOpenCodeInstallation()
|
||||
|
||||
// #then should fail with installation hint
|
||||
expect(result.status).toBe("fail")
|
||||
expect(result.message).toContain("not installed")
|
||||
expect(result.details).toBeDefined()
|
||||
expect(result.details?.some((d) => d.includes("opencode.ai"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns warn when version below minimum", async () => {
|
||||
// #given old version installed
|
||||
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "1.0.100",
|
||||
path: "/usr/local/bin/opencode",
|
||||
binary: "opencode",
|
||||
})
|
||||
|
||||
// #when checking installation
|
||||
const result = await opencode.checkOpenCodeInstallation()
|
||||
|
||||
// #then should warn about old version
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("below minimum")
|
||||
expect(result.details?.some((d) => d.includes(MIN_OPENCODE_VERSION))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns pass when properly installed", async () => {
|
||||
// #given current version installed
|
||||
getInfoSpy = spyOn(opencode, "getOpenCodeInfo").mockResolvedValue({
|
||||
installed: true,
|
||||
version: "1.0.200",
|
||||
path: "/usr/local/bin/opencode",
|
||||
binary: "opencode",
|
||||
})
|
||||
|
||||
// #when checking installation
|
||||
const result = await opencode.checkOpenCodeInstallation()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("1.0.200")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeCheckDefinition", () => {
|
||||
it("returns valid check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = opencode.getOpenCodeCheckDefinition()
|
||||
|
||||
// #then should have required properties
|
||||
expect(def.id).toBe("opencode-installation")
|
||||
expect(def.category).toBe("installation")
|
||||
expect(def.critical).toBe(true)
|
||||
expect(typeof def.check).toBe("function")
|
||||
})
|
||||
})
|
||||
})
|
||||
118
src/cli/doctor/checks/opencode.ts
Normal file
118
src/cli/doctor/checks/opencode.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { CheckResult, CheckDefinition, OpenCodeInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, MIN_OPENCODE_VERSION, OPENCODE_BINARIES } from "../constants"
|
||||
|
||||
export async function findOpenCodeBinary(): Promise<{ binary: string; path: string } | null> {
|
||||
for (const binary of OPENCODE_BINARIES) {
|
||||
try {
|
||||
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { binary, path: output.trim() }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export async function getOpenCodeVersion(binary: string): Promise<string | null> {
|
||||
try {
|
||||
const proc = Bun.spawn([binary, "--version"], { stdout: "pipe", stderr: "pipe" })
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return output.trim()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function compareVersions(current: string, minimum: string): boolean {
|
||||
const parseVersion = (v: string): number[] => {
|
||||
const cleaned = v.replace(/^v/, "").split("-")[0]
|
||||
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
const curr = parseVersion(current)
|
||||
const min = parseVersion(minimum)
|
||||
|
||||
for (let i = 0; i < Math.max(curr.length, min.length); i++) {
|
||||
const c = curr[i] ?? 0
|
||||
const m = min[i] ?? 0
|
||||
if (c > m) return true
|
||||
if (c < m) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function getOpenCodeInfo(): Promise<OpenCodeInfo> {
|
||||
const binaryInfo = await findOpenCodeBinary()
|
||||
|
||||
if (!binaryInfo) {
|
||||
return {
|
||||
installed: false,
|
||||
version: null,
|
||||
path: null,
|
||||
binary: null,
|
||||
}
|
||||
}
|
||||
|
||||
const version = await getOpenCodeVersion(binaryInfo.binary)
|
||||
|
||||
return {
|
||||
installed: true,
|
||||
version,
|
||||
path: binaryInfo.path,
|
||||
binary: binaryInfo.binary as "opencode" | "opencode-desktop",
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkOpenCodeInstallation(): Promise<CheckResult> {
|
||||
const info = await getOpenCodeInfo()
|
||||
|
||||
if (!info.installed) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
|
||||
status: "fail",
|
||||
message: "OpenCode is not installed",
|
||||
details: [
|
||||
"Visit: https://opencode.ai/docs for installation instructions",
|
||||
"Run: npm install -g opencode",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (info.version && !compareVersions(info.version, MIN_OPENCODE_VERSION)) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
|
||||
status: "warn",
|
||||
message: `Version ${info.version} is below minimum ${MIN_OPENCODE_VERSION}`,
|
||||
details: [
|
||||
`Current: ${info.version}`,
|
||||
`Required: >= ${MIN_OPENCODE_VERSION}`,
|
||||
"Run: npm update -g opencode",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
|
||||
status: "pass",
|
||||
message: info.version ?? "installed",
|
||||
details: info.path ? [`Path: ${info.path}`] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenCodeCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.OPENCODE_INSTALLATION,
|
||||
name: CHECK_NAMES[CHECK_IDS.OPENCODE_INSTALLATION],
|
||||
category: "installation",
|
||||
check: checkOpenCodeInstallation,
|
||||
critical: true,
|
||||
}
|
||||
}
|
||||
109
src/cli/doctor/checks/plugin.test.ts
Normal file
109
src/cli/doctor/checks/plugin.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as plugin from "./plugin"
|
||||
|
||||
describe("plugin check", () => {
|
||||
describe("getPluginInfo", () => {
|
||||
it("returns registered: false when config not found", () => {
|
||||
// #given no config file exists
|
||||
// #when getting plugin info
|
||||
// #then should indicate not registered
|
||||
const info = plugin.getPluginInfo()
|
||||
expect(typeof info.registered).toBe("boolean")
|
||||
expect(typeof info.isPinned).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkPluginRegistration", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns fail when config file not found", async () => {
|
||||
// #given no config file
|
||||
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
|
||||
registered: false,
|
||||
configPath: null,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
})
|
||||
|
||||
// #when checking registration
|
||||
const result = await plugin.checkPluginRegistration()
|
||||
|
||||
// #then should fail with hint
|
||||
expect(result.status).toBe("fail")
|
||||
expect(result.message).toContain("not found")
|
||||
})
|
||||
|
||||
it("returns fail when plugin not registered", async () => {
|
||||
// #given config exists but plugin not registered
|
||||
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
|
||||
registered: false,
|
||||
configPath: "/home/user/.config/opencode/opencode.json",
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
})
|
||||
|
||||
// #when checking registration
|
||||
const result = await plugin.checkPluginRegistration()
|
||||
|
||||
// #then should fail
|
||||
expect(result.status).toBe("fail")
|
||||
expect(result.message).toContain("not registered")
|
||||
})
|
||||
|
||||
it("returns pass when plugin registered", async () => {
|
||||
// #given plugin registered
|
||||
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
|
||||
registered: true,
|
||||
configPath: "/home/user/.config/opencode/opencode.json",
|
||||
entry: "oh-my-opencode",
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
})
|
||||
|
||||
// #when checking registration
|
||||
const result = await plugin.checkPluginRegistration()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("Registered")
|
||||
})
|
||||
|
||||
it("indicates pinned version when applicable", async () => {
|
||||
// #given plugin pinned to version
|
||||
getInfoSpy = spyOn(plugin, "getPluginInfo").mockReturnValue({
|
||||
registered: true,
|
||||
configPath: "/home/user/.config/opencode/opencode.json",
|
||||
entry: "oh-my-opencode@2.7.0",
|
||||
isPinned: true,
|
||||
pinnedVersion: "2.7.0",
|
||||
})
|
||||
|
||||
// #when checking registration
|
||||
const result = await plugin.checkPluginRegistration()
|
||||
|
||||
// #then should show pinned version
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("pinned")
|
||||
expect(result.message).toContain("2.7.0")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPluginCheckDefinition", () => {
|
||||
it("returns valid check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = plugin.getPluginCheckDefinition()
|
||||
|
||||
// #then should have required properties
|
||||
expect(def.id).toBe("plugin-registration")
|
||||
expect(def.category).toBe("installation")
|
||||
expect(def.critical).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
127
src/cli/doctor/checks/plugin.ts
Normal file
127
src/cli/doctor/checks/plugin.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
|
||||
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { path: OPENCODE_JSONC, format: "jsonc" }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { path: OPENCODE_JSON, format: "json" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean; version: string | null } | null {
|
||||
for (const plugin of plugins) {
|
||||
if (plugin === PACKAGE_NAME || plugin.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const isPinned = plugin.includes("@")
|
||||
const version = isPinned ? plugin.split("@")[1] : null
|
||||
return { entry: plugin, isPinned, version }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getPluginInfo(): PluginInfo {
|
||||
const configInfo = detectConfigPath()
|
||||
|
||||
if (!configInfo) {
|
||||
return {
|
||||
registered: false,
|
||||
configPath: null,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configInfo.path, "utf-8")
|
||||
const config = parseJsonc<{ plugin?: string[] }>(content)
|
||||
const plugins = config.plugin ?? []
|
||||
const pluginEntry = findPluginEntry(plugins)
|
||||
|
||||
if (!pluginEntry) {
|
||||
return {
|
||||
registered: false,
|
||||
configPath: configInfo.path,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registered: true,
|
||||
configPath: configInfo.path,
|
||||
entry: pluginEntry.entry,
|
||||
isPinned: pluginEntry.isPinned,
|
||||
pinnedVersion: pluginEntry.version,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
registered: false,
|
||||
configPath: configInfo.path,
|
||||
entry: null,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkPluginRegistration(): Promise<CheckResult> {
|
||||
const info = getPluginInfo()
|
||||
|
||||
if (!info.configPath) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "fail",
|
||||
message: "OpenCode config file not found",
|
||||
details: [
|
||||
"Run: bunx oh-my-opencode install",
|
||||
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.registered) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "fail",
|
||||
message: "Plugin not registered in config",
|
||||
details: [
|
||||
"Run: bunx oh-my-opencode install",
|
||||
`Config: ${info.configPath}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const message = info.isPinned
|
||||
? `Registered (pinned: ${info.pinnedVersion})`
|
||||
: "Registered"
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "pass",
|
||||
message,
|
||||
details: [`Config: ${info.configPath}`],
|
||||
}
|
||||
}
|
||||
|
||||
export function getPluginCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.PLUGIN_REGISTRATION,
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
category: "installation",
|
||||
check: checkPluginRegistration,
|
||||
critical: true,
|
||||
}
|
||||
}
|
||||
148
src/cli/doctor/checks/version.test.ts
Normal file
148
src/cli/doctor/checks/version.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import * as version from "./version"
|
||||
|
||||
describe("version check", () => {
|
||||
describe("getVersionInfo", () => {
|
||||
it("returns version check info structure", async () => {
|
||||
// #given
|
||||
// #when getting version info
|
||||
const info = await version.getVersionInfo()
|
||||
|
||||
// #then should have expected structure
|
||||
expect(typeof info.isUpToDate).toBe("boolean")
|
||||
expect(typeof info.isLocalDev).toBe("boolean")
|
||||
expect(typeof info.isPinned).toBe("boolean")
|
||||
})
|
||||
})
|
||||
|
||||
describe("checkVersionStatus", () => {
|
||||
let getInfoSpy: ReturnType<typeof spyOn>
|
||||
|
||||
afterEach(() => {
|
||||
getInfoSpy?.mockRestore()
|
||||
})
|
||||
|
||||
it("returns pass when in local dev mode", async () => {
|
||||
// #given local dev mode
|
||||
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
|
||||
currentVersion: "local-dev",
|
||||
latestVersion: "2.7.0",
|
||||
isUpToDate: true,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await version.checkVersionStatus()
|
||||
|
||||
// #then should pass with dev message
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("local development")
|
||||
})
|
||||
|
||||
it("returns pass when pinned", async () => {
|
||||
// #given pinned version
|
||||
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
|
||||
currentVersion: "2.6.0",
|
||||
latestVersion: "2.7.0",
|
||||
isUpToDate: true,
|
||||
isLocalDev: false,
|
||||
isPinned: true,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await version.checkVersionStatus()
|
||||
|
||||
// #then should pass with pinned message
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("Pinned")
|
||||
})
|
||||
|
||||
it("returns warn when unable to determine version", async () => {
|
||||
// #given no version info
|
||||
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
|
||||
currentVersion: null,
|
||||
latestVersion: "2.7.0",
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await version.checkVersionStatus()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("Unable to determine")
|
||||
})
|
||||
|
||||
it("returns warn when network error", async () => {
|
||||
// #given network error
|
||||
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
|
||||
currentVersion: "2.6.0",
|
||||
latestVersion: null,
|
||||
isUpToDate: true,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await version.checkVersionStatus()
|
||||
|
||||
// #then should warn
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.details?.some((d) => d.includes("network"))).toBe(true)
|
||||
})
|
||||
|
||||
it("returns warn when update available", async () => {
|
||||
// #given update available
|
||||
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
|
||||
currentVersion: "2.6.0",
|
||||
latestVersion: "2.7.0",
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await version.checkVersionStatus()
|
||||
|
||||
// #then should warn with update info
|
||||
expect(result.status).toBe("warn")
|
||||
expect(result.message).toContain("Update available")
|
||||
expect(result.message).toContain("2.6.0")
|
||||
expect(result.message).toContain("2.7.0")
|
||||
})
|
||||
|
||||
it("returns pass when up to date", async () => {
|
||||
// #given up to date
|
||||
getInfoSpy = spyOn(version, "getVersionInfo").mockResolvedValue({
|
||||
currentVersion: "2.7.0",
|
||||
latestVersion: "2.7.0",
|
||||
isUpToDate: true,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
})
|
||||
|
||||
// #when checking
|
||||
const result = await version.checkVersionStatus()
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("Up to date")
|
||||
})
|
||||
})
|
||||
|
||||
describe("getVersionCheckDefinition", () => {
|
||||
it("returns valid check definition", () => {
|
||||
// #given
|
||||
// #when getting definition
|
||||
const def = version.getVersionCheckDefinition()
|
||||
|
||||
// #then should have required properties
|
||||
expect(def.id).toBe("version-status")
|
||||
expect(def.category).toBe("updates")
|
||||
expect(def.critical).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
177
src/cli/doctor/checks/version.ts
Normal file
177
src/cli/doctor/checks/version.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, VersionCheckInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
|
||||
async function fetchLatestVersion(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
if (!res.ok) return null
|
||||
const data = (await res.json()) as { version: string }
|
||||
return data.version
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getCurrentVersion(): {
|
||||
version: string | null
|
||||
isLocalDev: boolean
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
} {
|
||||
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
|
||||
|
||||
if (!existsSync(configPath)) {
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(configPath, "utf-8")
|
||||
const config = parseJsonc<{ plugin?: string[] }>(content)
|
||||
const plugins = config.plugin ?? []
|
||||
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.startsWith("file:") && plugin.includes(PACKAGE_NAME)) {
|
||||
return { version: "local-dev", isLocalDev: true, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
if (plugin.startsWith(`${PACKAGE_NAME}@`)) {
|
||||
const pinnedVersion = plugin.split("@")[1]
|
||||
return { version: pinnedVersion, isLocalDev: false, isPinned: true, pinnedVersion }
|
||||
}
|
||||
if (plugin === PACKAGE_NAME) {
|
||||
if (existsSync(OPENCODE_PACKAGE_JSON)) {
|
||||
try {
|
||||
const pkgContent = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
|
||||
const pkg = JSON.parse(pkgContent) as { dependencies?: Record<string, string> }
|
||||
const depVersion = pkg.dependencies?.[PACKAGE_NAME]
|
||||
if (depVersion) {
|
||||
const cleanVersion = depVersion.replace(/^[\^~]/, "")
|
||||
return { version: cleanVersion, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
} catch {
|
||||
// intentionally empty - parse errors ignored
|
||||
}
|
||||
}
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
}
|
||||
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
} catch {
|
||||
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
|
||||
}
|
||||
}
|
||||
|
||||
function compareVersions(current: string, latest: string): boolean {
|
||||
const parseVersion = (v: string): number[] => {
|
||||
const cleaned = v.replace(/^v/, "").split("-")[0]
|
||||
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
const curr = parseVersion(current)
|
||||
const lat = parseVersion(latest)
|
||||
|
||||
for (let i = 0; i < Math.max(curr.length, lat.length); i++) {
|
||||
const c = curr[i] ?? 0
|
||||
const l = lat[i] ?? 0
|
||||
if (c < l) return false
|
||||
if (c > l) return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export async function getVersionInfo(): Promise<VersionCheckInfo> {
|
||||
const current = getCurrentVersion()
|
||||
const latestVersion = await fetchLatestVersion()
|
||||
|
||||
const isUpToDate =
|
||||
current.isLocalDev ||
|
||||
current.isPinned ||
|
||||
!current.version ||
|
||||
!latestVersion ||
|
||||
compareVersions(current.version, latestVersion)
|
||||
|
||||
return {
|
||||
currentVersion: current.version,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
isLocalDev: current.isLocalDev,
|
||||
isPinned: current.isPinned,
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkVersionStatus(): Promise<CheckResult> {
|
||||
const info = await getVersionInfo()
|
||||
|
||||
if (info.isLocalDev) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
status: "pass",
|
||||
message: "Running in local development mode",
|
||||
details: ["Using file:// protocol from config"],
|
||||
}
|
||||
}
|
||||
|
||||
if (info.isPinned) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
status: "pass",
|
||||
message: `Pinned to version ${info.currentVersion}`,
|
||||
details: ["Update check skipped for pinned versions"],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.currentVersion) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
status: "warn",
|
||||
message: "Unable to determine current version",
|
||||
details: ["Run: bunx oh-my-opencode get-local-version"],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.latestVersion) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
status: "warn",
|
||||
message: `Current: ${info.currentVersion}`,
|
||||
details: ["Unable to check for updates (network error)"],
|
||||
}
|
||||
}
|
||||
|
||||
if (!info.isUpToDate) {
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
status: "warn",
|
||||
message: `Update available: ${info.currentVersion} -> ${info.latestVersion}`,
|
||||
details: ["Run: cd ~/.config/opencode && bun update oh-my-opencode"],
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
status: "pass",
|
||||
message: `Up to date (${info.currentVersion})`,
|
||||
details: info.latestVersion ? [`Latest: ${info.latestVersion}`] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function getVersionCheckDefinition(): CheckDefinition {
|
||||
return {
|
||||
id: CHECK_IDS.VERSION_STATUS,
|
||||
name: CHECK_NAMES[CHECK_IDS.VERSION_STATUS],
|
||||
category: "updates",
|
||||
check: checkVersionStatus,
|
||||
critical: false,
|
||||
}
|
||||
}
|
||||
70
src/cli/doctor/constants.ts
Normal file
70
src/cli/doctor/constants.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import color from "picocolors"
|
||||
|
||||
export const SYMBOLS = {
|
||||
check: color.green("\u2713"),
|
||||
cross: color.red("\u2717"),
|
||||
warn: color.yellow("\u26A0"),
|
||||
info: color.blue("\u2139"),
|
||||
arrow: color.cyan("\u2192"),
|
||||
bullet: color.dim("\u2022"),
|
||||
skip: color.dim("\u25CB"),
|
||||
} as const
|
||||
|
||||
export const STATUS_COLORS = {
|
||||
pass: color.green,
|
||||
fail: color.red,
|
||||
warn: color.yellow,
|
||||
skip: color.dim,
|
||||
} as const
|
||||
|
||||
export const CHECK_IDS = {
|
||||
OPENCODE_INSTALLATION: "opencode-installation",
|
||||
PLUGIN_REGISTRATION: "plugin-registration",
|
||||
CONFIG_VALIDATION: "config-validation",
|
||||
AUTH_ANTHROPIC: "auth-anthropic",
|
||||
AUTH_OPENAI: "auth-openai",
|
||||
AUTH_GOOGLE: "auth-google",
|
||||
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
|
||||
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
|
||||
DEP_COMMENT_CHECKER: "dep-comment-checker",
|
||||
LSP_SERVERS: "lsp-servers",
|
||||
MCP_BUILTIN: "mcp-builtin",
|
||||
MCP_USER: "mcp-user",
|
||||
VERSION_STATUS: "version-status",
|
||||
} as const
|
||||
|
||||
export const CHECK_NAMES: Record<string, string> = {
|
||||
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
|
||||
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration",
|
||||
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
|
||||
[CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth",
|
||||
[CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth",
|
||||
[CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth",
|
||||
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
|
||||
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
|
||||
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
|
||||
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
|
||||
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
|
||||
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
|
||||
[CHECK_IDS.VERSION_STATUS]: "Version Status",
|
||||
} as const
|
||||
|
||||
export const CATEGORY_NAMES: Record<string, string> = {
|
||||
installation: "Installation",
|
||||
configuration: "Configuration",
|
||||
authentication: "Authentication",
|
||||
dependencies: "Dependencies",
|
||||
tools: "Tools & Servers",
|
||||
updates: "Updates",
|
||||
} as const
|
||||
|
||||
export const EXIT_CODES = {
|
||||
SUCCESS: 0,
|
||||
FAILURE: 1,
|
||||
} as const
|
||||
|
||||
export const MIN_OPENCODE_VERSION = "1.0.150"
|
||||
|
||||
export const PACKAGE_NAME = "oh-my-opencode"
|
||||
|
||||
export const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
218
src/cli/doctor/formatter.test.ts
Normal file
218
src/cli/doctor/formatter.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
formatStatusSymbol,
|
||||
formatCheckResult,
|
||||
formatCategoryHeader,
|
||||
formatSummary,
|
||||
formatHeader,
|
||||
formatFooter,
|
||||
formatJsonOutput,
|
||||
formatBox,
|
||||
formatHelpSuggestions,
|
||||
} from "./formatter"
|
||||
import type { CheckResult, DoctorSummary, DoctorResult } from "./types"
|
||||
|
||||
describe("formatter", () => {
|
||||
describe("formatStatusSymbol", () => {
|
||||
it("returns green check for pass", () => {
|
||||
const symbol = formatStatusSymbol("pass")
|
||||
expect(symbol).toContain("\u2713")
|
||||
})
|
||||
|
||||
it("returns red cross for fail", () => {
|
||||
const symbol = formatStatusSymbol("fail")
|
||||
expect(symbol).toContain("\u2717")
|
||||
})
|
||||
|
||||
it("returns yellow warning for warn", () => {
|
||||
const symbol = formatStatusSymbol("warn")
|
||||
expect(symbol).toContain("\u26A0")
|
||||
})
|
||||
|
||||
it("returns dim circle for skip", () => {
|
||||
const symbol = formatStatusSymbol("skip")
|
||||
expect(symbol).toContain("\u25CB")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatCheckResult", () => {
|
||||
it("includes name and message", () => {
|
||||
const result: CheckResult = {
|
||||
name: "Test Check",
|
||||
status: "pass",
|
||||
message: "All good",
|
||||
}
|
||||
|
||||
const output = formatCheckResult(result, false)
|
||||
|
||||
expect(output).toContain("Test Check")
|
||||
expect(output).toContain("All good")
|
||||
})
|
||||
|
||||
it("includes details when verbose", () => {
|
||||
const result: CheckResult = {
|
||||
name: "Test Check",
|
||||
status: "pass",
|
||||
message: "OK",
|
||||
details: ["Detail 1", "Detail 2"],
|
||||
}
|
||||
|
||||
const output = formatCheckResult(result, true)
|
||||
|
||||
expect(output).toContain("Detail 1")
|
||||
expect(output).toContain("Detail 2")
|
||||
})
|
||||
|
||||
it("hides details when not verbose", () => {
|
||||
const result: CheckResult = {
|
||||
name: "Test Check",
|
||||
status: "pass",
|
||||
message: "OK",
|
||||
details: ["Detail 1"],
|
||||
}
|
||||
|
||||
const output = formatCheckResult(result, false)
|
||||
|
||||
expect(output).not.toContain("Detail 1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatCategoryHeader", () => {
|
||||
it("formats category name with styling", () => {
|
||||
const header = formatCategoryHeader("installation")
|
||||
|
||||
expect(header).toContain("Installation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatSummary", () => {
|
||||
it("shows all counts", () => {
|
||||
const summary: DoctorSummary = {
|
||||
total: 10,
|
||||
passed: 7,
|
||||
failed: 1,
|
||||
warnings: 2,
|
||||
skipped: 0,
|
||||
duration: 150,
|
||||
}
|
||||
|
||||
const output = formatSummary(summary)
|
||||
|
||||
expect(output).toContain("7 passed")
|
||||
expect(output).toContain("1 failed")
|
||||
expect(output).toContain("2 warnings")
|
||||
expect(output).toContain("10 checks")
|
||||
expect(output).toContain("150ms")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatHeader", () => {
|
||||
it("includes doctor branding", () => {
|
||||
const header = formatHeader()
|
||||
|
||||
expect(header).toContain("Doctor")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatFooter", () => {
|
||||
it("shows error message when failures", () => {
|
||||
const summary: DoctorSummary = {
|
||||
total: 5,
|
||||
passed: 4,
|
||||
failed: 1,
|
||||
warnings: 0,
|
||||
skipped: 0,
|
||||
duration: 100,
|
||||
}
|
||||
|
||||
const footer = formatFooter(summary)
|
||||
|
||||
expect(footer).toContain("Issues detected")
|
||||
})
|
||||
|
||||
it("shows warning message when warnings only", () => {
|
||||
const summary: DoctorSummary = {
|
||||
total: 5,
|
||||
passed: 4,
|
||||
failed: 0,
|
||||
warnings: 1,
|
||||
skipped: 0,
|
||||
duration: 100,
|
||||
}
|
||||
|
||||
const footer = formatFooter(summary)
|
||||
|
||||
expect(footer).toContain("warnings")
|
||||
})
|
||||
|
||||
it("shows success message when all pass", () => {
|
||||
const summary: DoctorSummary = {
|
||||
total: 5,
|
||||
passed: 5,
|
||||
failed: 0,
|
||||
warnings: 0,
|
||||
skipped: 0,
|
||||
duration: 100,
|
||||
}
|
||||
|
||||
const footer = formatFooter(summary)
|
||||
|
||||
expect(footer).toContain("operational")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatJsonOutput", () => {
|
||||
it("returns valid JSON", () => {
|
||||
const result: DoctorResult = {
|
||||
results: [{ name: "Test", status: "pass", message: "OK" }],
|
||||
summary: { total: 1, passed: 1, failed: 0, warnings: 0, skipped: 0, duration: 50 },
|
||||
exitCode: 0,
|
||||
}
|
||||
|
||||
const output = formatJsonOutput(result)
|
||||
const parsed = JSON.parse(output)
|
||||
|
||||
expect(parsed.results.length).toBe(1)
|
||||
expect(parsed.summary.total).toBe(1)
|
||||
expect(parsed.exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatBox", () => {
|
||||
it("wraps content in box", () => {
|
||||
const box = formatBox("Test content")
|
||||
|
||||
expect(box).toContain("Test content")
|
||||
expect(box).toContain("\u2500")
|
||||
})
|
||||
|
||||
it("includes title when provided", () => {
|
||||
const box = formatBox("Content", "My Title")
|
||||
|
||||
expect(box).toContain("My Title")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatHelpSuggestions", () => {
|
||||
it("extracts suggestions from failed checks", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "Test", status: "fail", message: "Error", details: ["Run: fix-command"] },
|
||||
{ name: "OK", status: "pass", message: "Good" },
|
||||
]
|
||||
|
||||
const suggestions = formatHelpSuggestions(results)
|
||||
|
||||
expect(suggestions).toContain("Run: fix-command")
|
||||
})
|
||||
|
||||
it("returns empty array when no failures", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "OK", status: "pass", message: "Good" },
|
||||
]
|
||||
|
||||
const suggestions = formatHelpSuggestions(results)
|
||||
|
||||
expect(suggestions.length).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
140
src/cli/doctor/formatter.ts
Normal file
140
src/cli/doctor/formatter.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import color from "picocolors"
|
||||
import type { CheckResult, DoctorSummary, CheckCategory, DoctorResult } from "./types"
|
||||
import { SYMBOLS, STATUS_COLORS, CATEGORY_NAMES } from "./constants"
|
||||
|
||||
export function formatStatusSymbol(status: CheckResult["status"]): string {
|
||||
switch (status) {
|
||||
case "pass":
|
||||
return SYMBOLS.check
|
||||
case "fail":
|
||||
return SYMBOLS.cross
|
||||
case "warn":
|
||||
return SYMBOLS.warn
|
||||
case "skip":
|
||||
return SYMBOLS.skip
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCheckResult(result: CheckResult, verbose: boolean): string {
|
||||
const symbol = formatStatusSymbol(result.status)
|
||||
const colorFn = STATUS_COLORS[result.status]
|
||||
const name = colorFn(result.name)
|
||||
const message = color.dim(result.message)
|
||||
|
||||
let line = ` ${symbol} ${name}`
|
||||
if (result.message) {
|
||||
line += ` ${SYMBOLS.arrow} ${message}`
|
||||
}
|
||||
|
||||
if (verbose && result.details && result.details.length > 0) {
|
||||
const detailLines = result.details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
|
||||
line += "\n" + detailLines
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
|
||||
export function formatCategoryHeader(category: CheckCategory): string {
|
||||
const name = CATEGORY_NAMES[category] || category
|
||||
return `\n${color.bold(color.white(name))}\n${color.dim("\u2500".repeat(40))}`
|
||||
}
|
||||
|
||||
export function formatSummary(summary: DoctorSummary): string {
|
||||
const lines: string[] = []
|
||||
|
||||
lines.push(color.bold(color.white("Summary")))
|
||||
lines.push(color.dim("\u2500".repeat(40)))
|
||||
lines.push("")
|
||||
|
||||
const passText = summary.passed > 0 ? color.green(`${summary.passed} passed`) : color.dim("0 passed")
|
||||
const failText = summary.failed > 0 ? color.red(`${summary.failed} failed`) : color.dim("0 failed")
|
||||
const warnText = summary.warnings > 0 ? color.yellow(`${summary.warnings} warnings`) : color.dim("0 warnings")
|
||||
const skipText = summary.skipped > 0 ? color.dim(`${summary.skipped} skipped`) : ""
|
||||
|
||||
const parts = [passText, failText, warnText]
|
||||
if (skipText) parts.push(skipText)
|
||||
|
||||
lines.push(` ${parts.join(", ")}`)
|
||||
lines.push(` ${color.dim(`Total: ${summary.total} checks in ${summary.duration}ms`)}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatHeader(): string {
|
||||
return `\n${color.bgMagenta(color.white(" oMoMoMoMo... Doctor "))}\n`
|
||||
}
|
||||
|
||||
export function formatFooter(summary: DoctorSummary): string {
|
||||
if (summary.failed > 0) {
|
||||
return `\n${SYMBOLS.cross} ${color.red("Issues detected. Please review the errors above.")}\n`
|
||||
}
|
||||
if (summary.warnings > 0) {
|
||||
return `\n${SYMBOLS.warn} ${color.yellow("All systems operational with warnings.")}\n`
|
||||
}
|
||||
return `\n${SYMBOLS.check} ${color.green("All systems operational!")}\n`
|
||||
}
|
||||
|
||||
export function formatProgress(current: number, total: number, name: string): string {
|
||||
const progress = color.dim(`[${current}/${total}]`)
|
||||
return `${progress} Checking ${name}...`
|
||||
}
|
||||
|
||||
export function formatJsonOutput(result: DoctorResult): string {
|
||||
return JSON.stringify(result, null, 2)
|
||||
}
|
||||
|
||||
export function formatDetails(details: string[]): string {
|
||||
return details.map((d) => ` ${SYMBOLS.bullet} ${color.dim(d)}`).join("\n")
|
||||
}
|
||||
|
||||
function stripAnsi(str: string): string {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
return str.replace(/\x1b\[[0-9;]*m/g, "")
|
||||
}
|
||||
|
||||
export function formatBox(content: string, title?: string): string {
|
||||
const lines = content.split("\n")
|
||||
const maxWidth = Math.max(...lines.map((l) => stripAnsi(l).length), title?.length ?? 0) + 4
|
||||
const border = color.dim("\u2500".repeat(maxWidth))
|
||||
|
||||
const output: string[] = []
|
||||
output.push("")
|
||||
|
||||
if (title) {
|
||||
output.push(
|
||||
color.dim("\u250C\u2500") +
|
||||
color.bold(` ${title} `) +
|
||||
color.dim("\u2500".repeat(maxWidth - title.length - 4)) +
|
||||
color.dim("\u2510")
|
||||
)
|
||||
} else {
|
||||
output.push(color.dim("\u250C") + border + color.dim("\u2510"))
|
||||
}
|
||||
|
||||
for (const line of lines) {
|
||||
const stripped = stripAnsi(line)
|
||||
const padding = maxWidth - stripped.length
|
||||
output.push(color.dim("\u2502") + ` ${line}${" ".repeat(padding - 1)}` + color.dim("\u2502"))
|
||||
}
|
||||
|
||||
output.push(color.dim("\u2514") + border + color.dim("\u2518"))
|
||||
output.push("")
|
||||
|
||||
return output.join("\n")
|
||||
}
|
||||
|
||||
export function formatHelpSuggestions(results: CheckResult[]): string[] {
|
||||
const suggestions: string[] = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === "fail" && result.details) {
|
||||
for (const detail of result.details) {
|
||||
if (detail.includes("Run:") || detail.includes("Install:") || detail.includes("Visit:")) {
|
||||
suggestions.push(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions
|
||||
}
|
||||
11
src/cli/doctor/index.ts
Normal file
11
src/cli/doctor/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { DoctorOptions } from "./types"
|
||||
import { runDoctor } from "./runner"
|
||||
|
||||
export async function doctor(options: DoctorOptions = {}): Promise<number> {
|
||||
const result = await runDoctor(options)
|
||||
return result.exitCode
|
||||
}
|
||||
|
||||
export * from "./types"
|
||||
export { runDoctor } from "./runner"
|
||||
export { formatJsonOutput } from "./formatter"
|
||||
153
src/cli/doctor/runner.test.ts
Normal file
153
src/cli/doctor/runner.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, spyOn, afterEach } from "bun:test"
|
||||
import {
|
||||
runCheck,
|
||||
calculateSummary,
|
||||
determineExitCode,
|
||||
filterChecksByCategory,
|
||||
groupChecksByCategory,
|
||||
} from "./runner"
|
||||
import type { CheckResult, CheckDefinition, CheckCategory } from "./types"
|
||||
|
||||
describe("runner", () => {
|
||||
describe("runCheck", () => {
|
||||
it("returns result from check function", async () => {
|
||||
const check: CheckDefinition = {
|
||||
id: "test",
|
||||
name: "Test Check",
|
||||
category: "installation",
|
||||
check: async () => ({ name: "Test Check", status: "pass", message: "OK" }),
|
||||
}
|
||||
|
||||
const result = await runCheck(check)
|
||||
|
||||
expect(result.name).toBe("Test Check")
|
||||
expect(result.status).toBe("pass")
|
||||
})
|
||||
|
||||
it("measures duration", async () => {
|
||||
const check: CheckDefinition = {
|
||||
id: "test",
|
||||
name: "Test Check",
|
||||
category: "installation",
|
||||
check: async () => {
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
return { name: "Test", status: "pass", message: "OK" }
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCheck(check)
|
||||
|
||||
expect(result.duration).toBeGreaterThanOrEqual(10)
|
||||
})
|
||||
|
||||
it("returns fail on error", async () => {
|
||||
const check: CheckDefinition = {
|
||||
id: "test",
|
||||
name: "Test Check",
|
||||
category: "installation",
|
||||
check: async () => {
|
||||
throw new Error("Test error")
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCheck(check)
|
||||
|
||||
expect(result.status).toBe("fail")
|
||||
expect(result.message).toContain("Test error")
|
||||
})
|
||||
})
|
||||
|
||||
describe("calculateSummary", () => {
|
||||
it("counts each status correctly", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "pass", message: "" },
|
||||
{ name: "3", status: "fail", message: "" },
|
||||
{ name: "4", status: "warn", message: "" },
|
||||
{ name: "5", status: "skip", message: "" },
|
||||
]
|
||||
|
||||
const summary = calculateSummary(results, 100)
|
||||
|
||||
expect(summary.total).toBe(5)
|
||||
expect(summary.passed).toBe(2)
|
||||
expect(summary.failed).toBe(1)
|
||||
expect(summary.warnings).toBe(1)
|
||||
expect(summary.skipped).toBe(1)
|
||||
expect(summary.duration).toBe(100)
|
||||
})
|
||||
})
|
||||
|
||||
describe("determineExitCode", () => {
|
||||
it("returns 0 when all pass", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "pass", message: "" },
|
||||
]
|
||||
|
||||
expect(determineExitCode(results)).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 0 when only warnings", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "warn", message: "" },
|
||||
]
|
||||
|
||||
expect(determineExitCode(results)).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 1 when any failures", () => {
|
||||
const results: CheckResult[] = [
|
||||
{ name: "1", status: "pass", message: "" },
|
||||
{ name: "2", status: "fail", message: "" },
|
||||
]
|
||||
|
||||
expect(determineExitCode(results)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterChecksByCategory", () => {
|
||||
const checks: CheckDefinition[] = [
|
||||
{ id: "1", name: "Install", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
|
||||
{ id: "2", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
|
||||
{ id: "3", name: "Auth", category: "authentication", check: async () => ({ name: "", status: "pass", message: "" }) },
|
||||
]
|
||||
|
||||
it("returns all checks when no category", () => {
|
||||
const filtered = filterChecksByCategory(checks)
|
||||
|
||||
expect(filtered.length).toBe(3)
|
||||
})
|
||||
|
||||
it("filters to specific category", () => {
|
||||
const filtered = filterChecksByCategory(checks, "installation")
|
||||
|
||||
expect(filtered.length).toBe(1)
|
||||
expect(filtered[0].name).toBe("Install")
|
||||
})
|
||||
})
|
||||
|
||||
describe("groupChecksByCategory", () => {
|
||||
const checks: CheckDefinition[] = [
|
||||
{ id: "1", name: "Install1", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
|
||||
{ id: "2", name: "Install2", category: "installation", check: async () => ({ name: "", status: "pass", message: "" }) },
|
||||
{ id: "3", name: "Config", category: "configuration", check: async () => ({ name: "", status: "pass", message: "" }) },
|
||||
]
|
||||
|
||||
it("groups checks by category", () => {
|
||||
const groups = groupChecksByCategory(checks)
|
||||
|
||||
expect(groups.get("installation")?.length).toBe(2)
|
||||
expect(groups.get("configuration")?.length).toBe(1)
|
||||
})
|
||||
|
||||
it("maintains order within categories", () => {
|
||||
const groups = groupChecksByCategory(checks)
|
||||
const installChecks = groups.get("installation")!
|
||||
|
||||
expect(installChecks[0].name).toBe("Install1")
|
||||
expect(installChecks[1].name).toBe("Install2")
|
||||
})
|
||||
})
|
||||
})
|
||||
132
src/cli/doctor/runner.ts
Normal file
132
src/cli/doctor/runner.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import type {
|
||||
DoctorOptions,
|
||||
DoctorResult,
|
||||
CheckDefinition,
|
||||
CheckResult,
|
||||
DoctorSummary,
|
||||
CheckCategory,
|
||||
} from "./types"
|
||||
import { getAllCheckDefinitions } from "./checks"
|
||||
import { EXIT_CODES, CATEGORY_NAMES } from "./constants"
|
||||
import {
|
||||
formatHeader,
|
||||
formatCategoryHeader,
|
||||
formatCheckResult,
|
||||
formatSummary,
|
||||
formatFooter,
|
||||
formatJsonOutput,
|
||||
} from "./formatter"
|
||||
|
||||
export async function runCheck(check: CheckDefinition): Promise<CheckResult> {
|
||||
const start = performance.now()
|
||||
try {
|
||||
const result = await check.check()
|
||||
result.duration = Math.round(performance.now() - start)
|
||||
return result
|
||||
} catch (err) {
|
||||
return {
|
||||
name: check.name,
|
||||
status: "fail",
|
||||
message: err instanceof Error ? err.message : "Unknown error",
|
||||
duration: Math.round(performance.now() - start),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateSummary(results: CheckResult[], duration: number): DoctorSummary {
|
||||
return {
|
||||
total: results.length,
|
||||
passed: results.filter((r) => r.status === "pass").length,
|
||||
failed: results.filter((r) => r.status === "fail").length,
|
||||
warnings: results.filter((r) => r.status === "warn").length,
|
||||
skipped: results.filter((r) => r.status === "skip").length,
|
||||
duration: Math.round(duration),
|
||||
}
|
||||
}
|
||||
|
||||
export function determineExitCode(results: CheckResult[]): number {
|
||||
const hasFailures = results.some((r) => r.status === "fail")
|
||||
return hasFailures ? EXIT_CODES.FAILURE : EXIT_CODES.SUCCESS
|
||||
}
|
||||
|
||||
export function filterChecksByCategory(
|
||||
checks: CheckDefinition[],
|
||||
category?: CheckCategory
|
||||
): CheckDefinition[] {
|
||||
if (!category) return checks
|
||||
return checks.filter((c) => c.category === category)
|
||||
}
|
||||
|
||||
export function groupChecksByCategory(
|
||||
checks: CheckDefinition[]
|
||||
): Map<CheckCategory, CheckDefinition[]> {
|
||||
const groups = new Map<CheckCategory, CheckDefinition[]>()
|
||||
|
||||
for (const check of checks) {
|
||||
const existing = groups.get(check.category) ?? []
|
||||
existing.push(check)
|
||||
groups.set(check.category, existing)
|
||||
}
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
const CATEGORY_ORDER: CheckCategory[] = [
|
||||
"installation",
|
||||
"configuration",
|
||||
"authentication",
|
||||
"dependencies",
|
||||
"tools",
|
||||
"updates",
|
||||
]
|
||||
|
||||
export async function runDoctor(options: DoctorOptions): Promise<DoctorResult> {
|
||||
const start = performance.now()
|
||||
const allChecks = getAllCheckDefinitions()
|
||||
const filteredChecks = filterChecksByCategory(allChecks, options.category)
|
||||
const groupedChecks = groupChecksByCategory(filteredChecks)
|
||||
|
||||
const results: CheckResult[] = []
|
||||
|
||||
if (!options.json) {
|
||||
console.log(formatHeader())
|
||||
}
|
||||
|
||||
for (const category of CATEGORY_ORDER) {
|
||||
const checks = groupedChecks.get(category)
|
||||
if (!checks || checks.length === 0) continue
|
||||
|
||||
if (!options.json) {
|
||||
console.log(formatCategoryHeader(category))
|
||||
}
|
||||
|
||||
for (const check of checks) {
|
||||
const result = await runCheck(check)
|
||||
results.push(result)
|
||||
|
||||
if (!options.json) {
|
||||
console.log(formatCheckResult(result, options.verbose ?? false))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const duration = performance.now() - start
|
||||
const summary = calculateSummary(results, duration)
|
||||
const exitCode = determineExitCode(results)
|
||||
|
||||
const doctorResult: DoctorResult = {
|
||||
results,
|
||||
summary,
|
||||
exitCode,
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(formatJsonOutput(doctorResult))
|
||||
} else {
|
||||
console.log("")
|
||||
console.log(formatSummary(summary))
|
||||
console.log(formatFooter(summary))
|
||||
}
|
||||
|
||||
return doctorResult
|
||||
}
|
||||
113
src/cli/doctor/types.ts
Normal file
113
src/cli/doctor/types.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
export type CheckStatus = "pass" | "fail" | "warn" | "skip"
|
||||
|
||||
export interface CheckResult {
|
||||
name: string
|
||||
status: CheckStatus
|
||||
message: string
|
||||
details?: string[]
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export type CheckFunction = () => Promise<CheckResult>
|
||||
|
||||
export type CheckCategory =
|
||||
| "installation"
|
||||
| "configuration"
|
||||
| "authentication"
|
||||
| "dependencies"
|
||||
| "tools"
|
||||
| "updates"
|
||||
|
||||
export interface CheckDefinition {
|
||||
id: string
|
||||
name: string
|
||||
category: CheckCategory
|
||||
check: CheckFunction
|
||||
critical?: boolean
|
||||
}
|
||||
|
||||
export interface DoctorOptions {
|
||||
verbose?: boolean
|
||||
json?: boolean
|
||||
category?: CheckCategory
|
||||
}
|
||||
|
||||
export interface DoctorSummary {
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
warnings: number
|
||||
skipped: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface DoctorResult {
|
||||
results: CheckResult[]
|
||||
summary: DoctorSummary
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
export interface OpenCodeInfo {
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
binary: "opencode" | "opencode-desktop" | null
|
||||
}
|
||||
|
||||
export interface PluginInfo {
|
||||
registered: boolean
|
||||
configPath: string | null
|
||||
entry: string | null
|
||||
isPinned: boolean
|
||||
pinnedVersion: string | null
|
||||
}
|
||||
|
||||
export interface ConfigInfo {
|
||||
exists: boolean
|
||||
path: string | null
|
||||
format: "json" | "jsonc" | null
|
||||
valid: boolean
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export type AuthProviderId = "anthropic" | "openai" | "google"
|
||||
|
||||
export interface AuthProviderInfo {
|
||||
id: AuthProviderId
|
||||
name: string
|
||||
pluginInstalled: boolean
|
||||
configured: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface DependencyInfo {
|
||||
name: string
|
||||
required: boolean
|
||||
installed: boolean
|
||||
version: string | null
|
||||
path: string | null
|
||||
installHint?: string
|
||||
}
|
||||
|
||||
export interface LspServerInfo {
|
||||
id: string
|
||||
installed: boolean
|
||||
extensions: string[]
|
||||
source: "builtin" | "config" | "plugin"
|
||||
}
|
||||
|
||||
export interface McpServerInfo {
|
||||
id: string
|
||||
type: "builtin" | "user"
|
||||
enabled: boolean
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface VersionCheckInfo {
|
||||
currentVersion: string | null
|
||||
latestVersion: string | null
|
||||
isUpToDate: boolean
|
||||
isLocalDev: boolean
|
||||
isPinned: boolean
|
||||
}
|
||||
@@ -3,9 +3,11 @@ import { Command } from "commander"
|
||||
import { install } from "./install"
|
||||
import { run } from "./run"
|
||||
import { getLocalVersion } from "./get-local-version"
|
||||
import { doctor } from "./doctor"
|
||||
import type { InstallArgs } from "./types"
|
||||
import type { RunOptions } from "./run"
|
||||
import type { GetLocalVersionOptions } from "./get-local-version/types"
|
||||
import type { DoctorOptions } from "./doctor"
|
||||
|
||||
const packageJson = await import("../../package.json")
|
||||
const VERSION = packageJson.version
|
||||
@@ -101,6 +103,37 @@ This command shows:
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("doctor")
|
||||
.description("Check oh-my-opencode installation health and diagnose issues")
|
||||
.option("--verbose", "Show detailed diagnostic information")
|
||||
.option("--json", "Output results in JSON format")
|
||||
.option("--category <category>", "Run only specific category")
|
||||
.addHelpText("after", `
|
||||
Examples:
|
||||
$ bunx oh-my-opencode doctor
|
||||
$ bunx oh-my-opencode doctor --verbose
|
||||
$ bunx oh-my-opencode doctor --json
|
||||
$ bunx oh-my-opencode doctor --category authentication
|
||||
|
||||
Categories:
|
||||
installation Check OpenCode and plugin installation
|
||||
configuration Validate configuration files
|
||||
authentication Check auth provider status
|
||||
dependencies Check external dependencies
|
||||
tools Check LSP and MCP servers
|
||||
updates Check for version updates
|
||||
`)
|
||||
.action(async (options) => {
|
||||
const doctorOptions: DoctorOptions = {
|
||||
verbose: options.verbose ?? false,
|
||||
json: options.json ?? false,
|
||||
category: options.category,
|
||||
}
|
||||
const exitCode = await doctor(doctorOptions)
|
||||
process.exit(exitCode)
|
||||
})
|
||||
|
||||
program
|
||||
.command("version")
|
||||
.description("Show version information")
|
||||
|
||||
@@ -79,15 +79,11 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
|
||||
}
|
||||
|
||||
case "message.part.updated": {
|
||||
// Skip verbose logging for partial message updates
|
||||
// Only log tool invocation state changes, not text streaming
|
||||
const partProps = props as MessagePartUpdatedProps | undefined
|
||||
const role = partProps?.info?.role ?? "unknown"
|
||||
const part = partProps?.part
|
||||
if (part?.type === "text" && part.text) {
|
||||
const preview = part.text.slice(0, 100).replace(/\n/g, "\\n")
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (${role}): "${preview}${part.text.length > 100 ? "..." : ""}"`)
|
||||
)
|
||||
} else if (part?.type === "tool-invocation") {
|
||||
if (part?.type === "tool-invocation") {
|
||||
const toolPart = part as { toolName?: string; state?: string }
|
||||
console.error(
|
||||
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
|
||||
|
||||
@@ -5,8 +5,10 @@ export {
|
||||
McpNameSchema,
|
||||
AgentNameSchema,
|
||||
HookNameSchema,
|
||||
BuiltinCommandNameSchema,
|
||||
SisyphusAgentConfigSchema,
|
||||
ExperimentalConfigSchema,
|
||||
RalphLoopConfigSchema,
|
||||
} from "./schema"
|
||||
|
||||
export type {
|
||||
@@ -16,7 +18,9 @@ export type {
|
||||
McpName,
|
||||
AgentName,
|
||||
HookName,
|
||||
BuiltinCommandName,
|
||||
SisyphusAgentConfig,
|
||||
ExperimentalConfig,
|
||||
DynamicContextPruningConfig,
|
||||
RalphLoopConfig,
|
||||
} from "./schema"
|
||||
|
||||
@@ -54,7 +54,7 @@ export const HookNameSchema = z.enum([
|
||||
"directory-readme-injector",
|
||||
"empty-task-response-detector",
|
||||
"think-mode",
|
||||
"anthropic-auto-compact",
|
||||
"anthropic-context-window-limit-recovery",
|
||||
"rules-injector",
|
||||
"background-notification",
|
||||
"auto-update-checker",
|
||||
@@ -65,6 +65,11 @@ export const HookNameSchema = z.enum([
|
||||
"interactive-bash-session",
|
||||
"empty-message-sanitizer",
|
||||
"thinking-block-validator",
|
||||
"ralph-loop",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
"init-deep",
|
||||
])
|
||||
|
||||
export const AgentOverrideConfigSchema = z.object({
|
||||
@@ -115,6 +120,11 @@ export const SisyphusAgentConfigSchema = z.object({
|
||||
replace_plan: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const CommentCheckerConfigSchema = z.object({
|
||||
/** Custom prompt to replace the default warning message. Use {{comments}} placeholder for detected comments XML. */
|
||||
custom_prompt: z.string().optional(),
|
||||
})
|
||||
|
||||
export const DynamicContextPruningConfigSchema = z.object({
|
||||
/** Enable dynamic context pruning (default: false) */
|
||||
enabled: z.boolean().default(false),
|
||||
@@ -154,16 +164,63 @@ export const DynamicContextPruningConfigSchema = z.object({
|
||||
export const ExperimentalConfigSchema = z.object({
|
||||
aggressive_truncation: z.boolean().optional(),
|
||||
auto_resume: z.boolean().optional(),
|
||||
/** Enable preemptive compaction at threshold (default: true) */
|
||||
/** Enable preemptive compaction at threshold (default: false) */
|
||||
preemptive_compaction: z.boolean().optional(),
|
||||
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
|
||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
|
||||
truncate_all_tool_outputs: z.boolean().default(true),
|
||||
/** Truncate all tool outputs, not just whitelisted tools (default: false). Tool output truncator is enabled by default - disable via disabled_hooks. */
|
||||
truncate_all_tool_outputs: z.boolean().optional(),
|
||||
/** Dynamic context pruning configuration */
|
||||
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
|
||||
/** Run DCP only when compaction (summarize) fails, then retry compaction (default: false) */
|
||||
dcp_on_compaction_failure: z.boolean().optional(),
|
||||
/** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */
|
||||
dcp_for_compaction: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const SkillSourceSchema = z.union([
|
||||
z.string(),
|
||||
z.object({
|
||||
path: z.string(),
|
||||
recursive: z.boolean().optional(),
|
||||
glob: z.string().optional(),
|
||||
}),
|
||||
])
|
||||
|
||||
export const SkillDefinitionSchema = z.object({
|
||||
description: z.string().optional(),
|
||||
template: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
subtask: z.boolean().optional(),
|
||||
"argument-hint": z.string().optional(),
|
||||
license: z.string().optional(),
|
||||
compatibility: z.string().optional(),
|
||||
metadata: z.record(z.string(), z.unknown()).optional(),
|
||||
"allowed-tools": z.array(z.string()).optional(),
|
||||
disable: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const SkillEntrySchema = z.union([
|
||||
z.boolean(),
|
||||
SkillDefinitionSchema,
|
||||
])
|
||||
|
||||
export const SkillsConfigSchema = z.union([
|
||||
z.array(z.string()),
|
||||
z.record(z.string(), SkillEntrySchema).and(z.object({
|
||||
sources: z.array(SkillSourceSchema).optional(),
|
||||
enable: z.array(z.string()).optional(),
|
||||
disable: z.array(z.string()).optional(),
|
||||
}).partial()),
|
||||
])
|
||||
|
||||
export const RalphLoopConfigSchema = z.object({
|
||||
/** Enable ralph loop functionality (default: false - opt-in feature) */
|
||||
enabled: z.boolean().default(false),
|
||||
/** Default max iterations if not specified in command (default: 100) */
|
||||
default_max_iterations: z.number().min(1).max(1000).default(100),
|
||||
/** Custom state file directory relative to project root (default: .opencode/) */
|
||||
state_dir: z.string().optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
@@ -171,12 +228,16 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
|
||||
agents: AgentOverridesSchema.optional(),
|
||||
claude_code: ClaudeCodeConfigSchema.optional(),
|
||||
google_auth: z.boolean().optional(),
|
||||
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
|
||||
comment_checker: CommentCheckerConfigSchema.optional(),
|
||||
experimental: ExperimentalConfigSchema.optional(),
|
||||
auto_update: z.boolean().optional(),
|
||||
skills: SkillsConfigSchema.optional(),
|
||||
ralph_loop: RalphLoopConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
@@ -184,8 +245,13 @@ export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
||||
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
|
||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
|
||||
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
|
||||
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
|
||||
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
|
||||
|
||||
export { McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
@@ -12,12 +12,14 @@ features/
|
||||
│ ├── manager.ts # Task lifecycle, notifications
|
||||
│ ├── manager.test.ts
|
||||
│ └── types.ts
|
||||
├── builtin-commands/ # Built-in slash command definitions
|
||||
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
|
||||
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
|
||||
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
|
||||
│ └── env-expander.ts # ${VAR} expansion
|
||||
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
|
||||
├── claude-code-session-state/ # Session state persistence
|
||||
├── claude-code-skill-loader/ # Load skills from ~/.claude/skills/*/SKILL.md
|
||||
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
|
||||
└── hook-message-injector/ # Inject messages into conversation
|
||||
```
|
||||
|
||||
@@ -28,7 +30,7 @@ Each loader reads from multiple directories (highest priority first):
|
||||
| Loader | Priority Order |
|
||||
|--------|---------------|
|
||||
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
|
||||
| Skills | `.claude/skills/` > `~/.claude/skills/` |
|
||||
| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
|
||||
| Agents | `.claude/agents/` > `~/.claude/agents/` |
|
||||
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
|
||||
|
||||
|
||||
@@ -325,6 +325,7 @@ export class BackgroundManager {
|
||||
|
||||
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
@@ -344,10 +345,13 @@ export class BackgroundManager {
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
this.clearNotificationsForTask(task.id)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
|
||||
53
src/features/builtin-commands/commands.ts
Normal file
53
src/features/builtin-commands/commands.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
import type { BuiltinCommandName, BuiltinCommands } from "./types"
|
||||
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
|
||||
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
|
||||
|
||||
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
|
||||
"init-deep": {
|
||||
description: "(builtin) Initialize hierarchical AGENTS.md knowledge base",
|
||||
template: `<command-instruction>
|
||||
${INIT_DEEP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`,
|
||||
argumentHint: "[--create-new] [--max-depth=N]",
|
||||
},
|
||||
"ralph-loop": {
|
||||
description: "(builtin) Start self-referential development loop until completion",
|
||||
template: `<command-instruction>
|
||||
${RALPH_LOOP_TEMPLATE}
|
||||
</command-instruction>
|
||||
|
||||
<user-task>
|
||||
$ARGUMENTS
|
||||
</user-task>`,
|
||||
argumentHint: '"task description" [--completion-promise=TEXT] [--max-iterations=N]',
|
||||
},
|
||||
"cancel-ralph": {
|
||||
description: "(builtin) Cancel active Ralph Loop",
|
||||
template: `<command-instruction>
|
||||
${CANCEL_RALPH_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
},
|
||||
}
|
||||
|
||||
export function loadBuiltinCommands(
|
||||
disabledCommands?: BuiltinCommandName[]
|
||||
): BuiltinCommands {
|
||||
const disabled = new Set(disabledCommands ?? [])
|
||||
const commands: BuiltinCommands = {}
|
||||
|
||||
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
|
||||
if (!disabled.has(name as BuiltinCommandName)) {
|
||||
commands[name] = {
|
||||
name,
|
||||
...definition,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
2
src/features/builtin-commands/index.ts
Normal file
2
src/features/builtin-commands/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./commands"
|
||||
394
src/features/builtin-commands/templates/init-deep.ts
Normal file
394
src/features/builtin-commands/templates/init-deep.ts
Normal file
@@ -0,0 +1,394 @@
|
||||
export const INIT_DEEP_TEMPLATE = `# Initialize Deep Knowledge Base
|
||||
|
||||
Generate comprehensive AGENTS.md files across project hierarchy. Combines root-level project knowledge (gen-knowledge) with complexity-based subdirectory documentation (gen-knowledge-deep).
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`
|
||||
/init-deep # Analyze and generate hierarchical AGENTS.md
|
||||
/init-deep --create-new # Force create from scratch (ignore existing)
|
||||
/init-deep --max-depth=2 # Limit to N directory levels (default: 3)
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Telegraphic Style**: Sacrifice grammar for concision ("Project uses React" → "React 18")
|
||||
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
|
||||
- **Hierarchy Aware**: Parent covers general, children cover specific
|
||||
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
|
||||
- **LSP-First**: Use LSP tools for accurate code intelligence when available (semantic > text search)
|
||||
|
||||
---
|
||||
|
||||
## Process
|
||||
|
||||
<critical>
|
||||
**MANDATORY: TodoWrite for ALL phases. Mark in_progress → completed in real-time.**
|
||||
</critical>
|
||||
|
||||
### Phase 0: Initialize
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
{ id: "p1-analysis", content: "Parallel project structure & complexity analysis", status: "pending", priority: "high" },
|
||||
{ id: "p2-scoring", content: "Score directories, determine AGENTS.md locations", status: "pending", priority: "high" },
|
||||
{ id: "p3-root", content: "Generate root AGENTS.md with Predict-then-Compare", status: "pending", priority: "high" },
|
||||
{ id: "p4-subdirs", content: "Generate subdirectory AGENTS.md files in parallel", status: "pending", priority: "high" },
|
||||
{ id: "p5-review", content: "Review, deduplicate, validate all files", status: "pending", priority: "medium" }
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Parallel Project Analysis
|
||||
|
||||
**Mark "p1-analysis" as in_progress.**
|
||||
|
||||
Launch **ALL tasks simultaneously**:
|
||||
|
||||
<parallel-tasks>
|
||||
|
||||
### Structural Analysis (bash - run in parallel)
|
||||
\`\`\`bash
|
||||
# Task A: Directory depth analysis
|
||||
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
|
||||
|
||||
# Task B: File count per directory
|
||||
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
|
||||
|
||||
# Task C: Code concentration
|
||||
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \\) -not -path '*/node_modules/*' -not -path '*/venv/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
|
||||
|
||||
# Task D: Existing knowledge files
|
||||
find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null
|
||||
\`\`\`
|
||||
|
||||
### Context Gathering (Explore agents - background_task in parallel)
|
||||
|
||||
\`\`\`
|
||||
background_task(agent="explore", prompt="Project structure: PREDICT standard {lang} patterns → FIND package.json/pyproject.toml/go.mod → REPORT deviations only")
|
||||
|
||||
background_task(agent="explore", prompt="Entry points: PREDICT typical (main.py, index.ts) → FIND actual → REPORT non-standard organization")
|
||||
|
||||
background_task(agent="explore", prompt="Conventions: FIND .cursor/rules, .cursorrules, eslintrc, pyproject.toml → REPORT project-specific rules DIFFERENT from defaults")
|
||||
|
||||
background_task(agent="explore", prompt="Anti-patterns: FIND comments with 'DO NOT', 'NEVER', 'ALWAYS', 'LEGACY', 'DEPRECATED' → REPORT forbidden patterns")
|
||||
|
||||
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile, justfile → REPORT non-standard build/deploy patterns")
|
||||
|
||||
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
|
||||
\`\`\`
|
||||
|
||||
### Code Intelligence Analysis (LSP tools - run in parallel)
|
||||
|
||||
LSP provides semantic understanding beyond text search. Use for accurate code mapping.
|
||||
|
||||
\`\`\`
|
||||
# Step 1: Check LSP availability
|
||||
lsp_servers() # Verify language server is available
|
||||
|
||||
# Step 2: Analyze entry point files (run in parallel)
|
||||
# Find entry points first, then analyze each with lsp_document_symbols
|
||||
lsp_document_symbols(filePath="src/index.ts") # Main entry
|
||||
lsp_document_symbols(filePath="src/main.py") # Python entry
|
||||
lsp_document_symbols(filePath="cmd/main.go") # Go entry
|
||||
|
||||
# Step 3: Discover key symbols across workspace (run in parallel)
|
||||
lsp_workspace_symbols(filePath=".", query="class") # All classes
|
||||
lsp_workspace_symbols(filePath=".", query="interface") # All interfaces
|
||||
lsp_workspace_symbols(filePath=".", query="function") # Top-level functions
|
||||
lsp_workspace_symbols(filePath=".", query="type") # Type definitions
|
||||
|
||||
# Step 4: Analyze symbol centrality (for top 5-10 key symbols)
|
||||
# High reference count = central/important concept
|
||||
lsp_find_references(filePath="src/index.ts", line=X, character=Y) # Main export
|
||||
\`\`\`
|
||||
|
||||
#### LSP Analysis Output Format
|
||||
|
||||
\`\`\`
|
||||
CODE_INTELLIGENCE = {
|
||||
entry_points: [
|
||||
{ file: "src/index.ts", exports: ["Plugin", "createHook"], symbol_count: 12 }
|
||||
],
|
||||
key_symbols: [
|
||||
{ name: "Plugin", type: "class", file: "src/index.ts", refs: 45, role: "Central orchestrator" },
|
||||
{ name: "createHook", type: "function", file: "src/utils.ts", refs: 23, role: "Hook factory" }
|
||||
],
|
||||
module_boundaries: [
|
||||
{ dir: "src/hooks", exports: 21, imports_from: ["shared/"] },
|
||||
{ dir: "src/tools", exports: 15, imports_from: ["shared/", "hooks/"] }
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
<critical>
|
||||
**LSP Fallback**: If LSP unavailable (no server installed), skip this section and rely on explore agents + AST-grep patterns.
|
||||
</critical>
|
||||
|
||||
</parallel-tasks>
|
||||
|
||||
**Collect all results. Mark "p1-analysis" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Complexity Scoring & Location Decision
|
||||
|
||||
**Mark "p2-scoring" as in_progress.**
|
||||
|
||||
### Scoring Matrix
|
||||
|
||||
| Factor | Weight | Threshold | Source |
|
||||
|--------|--------|-----------|--------|
|
||||
| File count | 3x | >20 files = high | bash |
|
||||
| Subdirectory count | 2x | >5 subdirs = high | bash |
|
||||
| Code file ratio | 2x | >70% code = high | bash |
|
||||
| Unique patterns | 1x | Has own config | explore |
|
||||
| Module boundary | 2x | Has __init__.py/index.ts | bash |
|
||||
| **Symbol density** | 2x | >30 symbols = high | LSP |
|
||||
| **Export count** | 2x | >10 exports = high | LSP |
|
||||
| **Reference centrality** | 3x | Symbols with >20 refs | LSP |
|
||||
|
||||
<lsp-scoring>
|
||||
**LSP-Enhanced Scoring** (if available):
|
||||
|
||||
\`\`\`
|
||||
For each directory in candidates:
|
||||
symbols = lsp_document_symbols(dir/index.ts or dir/__init__.py)
|
||||
|
||||
symbol_score = len(symbols) > 30 ? 6 : len(symbols) > 15 ? 3 : 0
|
||||
export_score = count(exported symbols) > 10 ? 4 : 0
|
||||
|
||||
# Check if this module is central (many things depend on it)
|
||||
for each exported symbol:
|
||||
refs = lsp_find_references(symbol)
|
||||
if refs > 20: centrality_score += 3
|
||||
|
||||
total_score += symbol_score + export_score + centrality_score
|
||||
\`\`\`
|
||||
</lsp-scoring>
|
||||
|
||||
### Decision Rules
|
||||
|
||||
| Score | Action |
|
||||
|-------|--------|
|
||||
| **Root (.)** | ALWAYS create AGENTS.md |
|
||||
| **High (>15)** | Create dedicated AGENTS.md |
|
||||
| **Medium (8-15)** | Create if distinct domain |
|
||||
| **Low (<8)** | Skip, parent sufficient |
|
||||
|
||||
### Output Format
|
||||
|
||||
\`\`\`
|
||||
AGENTS_LOCATIONS = [
|
||||
{ path: ".", type: "root" },
|
||||
{ path: "src/api", score: 18, reason: "high complexity, 45 files" },
|
||||
{ path: "src/hooks", score: 12, reason: "distinct domain, unique patterns" },
|
||||
]
|
||||
\`\`\`
|
||||
|
||||
**Mark "p2-scoring" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Generate Root AGENTS.md
|
||||
|
||||
**Mark "p3-root" as in_progress.**
|
||||
|
||||
Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
|
||||
|
||||
### Required Sections
|
||||
|
||||
\`\`\`markdown
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** {TIMESTAMP}
|
||||
**Commit:** {SHORT_SHA}
|
||||
**Branch:** {BRANCH}
|
||||
|
||||
## OVERVIEW
|
||||
|
||||
{1-2 sentences: what project does, core tech stack}
|
||||
|
||||
## STRUCTURE
|
||||
|
||||
\\\`\\\`\\\`
|
||||
{project-root}/
|
||||
├── {dir}/ # {non-obvious purpose only}
|
||||
└── {entry} # entry point
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## WHERE TO LOOK
|
||||
|
||||
| Task | Location | Notes |
|
||||
|------|----------|-------|
|
||||
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
|
||||
|
||||
## CODE MAP
|
||||
|
||||
{Generated from LSP analysis - shows key symbols and their relationships}
|
||||
|
||||
| Symbol | Type | Location | Refs | Role |
|
||||
|--------|------|----------|------|------|
|
||||
| {MainClass} | Class | \\\`src/index.ts\\\` | {N} | {Central orchestrator} |
|
||||
| {createX} | Function | \\\`src/utils.ts\\\` | {N} | {Factory pattern} |
|
||||
| {Config} | Interface | \\\`src/types.ts\\\` | {N} | {Configuration contract} |
|
||||
|
||||
### Module Dependencies
|
||||
|
||||
\\\`\\\`\\\`
|
||||
{entry} ──imports──> {core/}
|
||||
│ │
|
||||
└──imports──> {utils/} <──imports── {features/}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
<code-map-note>
|
||||
**Skip CODE MAP if**: LSP unavailable OR project too small (<10 files) OR no clear module boundaries.
|
||||
</code-map-note>
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
{ONLY deviations from standard - skip generic advice}
|
||||
|
||||
- **{rule}**: {specific detail}
|
||||
|
||||
## ANTI-PATTERNS (THIS PROJECT)
|
||||
|
||||
{Things explicitly forbidden HERE}
|
||||
|
||||
- **{pattern}**: {why} → {alternative}
|
||||
|
||||
## UNIQUE STYLES
|
||||
|
||||
{Project-specific coding styles}
|
||||
|
||||
- **{style}**: {how different}
|
||||
|
||||
## COMMANDS
|
||||
|
||||
\\\`\\\`\\\`bash
|
||||
{dev-command}
|
||||
{test-command}
|
||||
{build-command}
|
||||
\\\`\\\`\\\`
|
||||
|
||||
## NOTES
|
||||
|
||||
{Gotchas, non-obvious info}
|
||||
\`\`\`
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] Size: 50-150 lines
|
||||
- [ ] No generic advice ("write clean code")
|
||||
- [ ] No obvious info ("tests/ has tests")
|
||||
- [ ] Every item is project-specific
|
||||
|
||||
**Mark "p3-root" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Generate Subdirectory AGENTS.md
|
||||
|
||||
**Mark "p4-subdirs" as in_progress.**
|
||||
|
||||
For each location in AGENTS_LOCATIONS (except root), launch **parallel document-writer agents**:
|
||||
|
||||
\`\`\`typescript
|
||||
for (const loc of AGENTS_LOCATIONS.filter(l => l.path !== ".")) {
|
||||
background_task({
|
||||
agent: "document-writer",
|
||||
prompt: \\\`
|
||||
Generate AGENTS.md for: \${loc.path}
|
||||
|
||||
CONTEXT:
|
||||
- Complexity reason: \${loc.reason}
|
||||
- Parent AGENTS.md: ./AGENTS.md (already covers project overview)
|
||||
|
||||
CRITICAL RULES:
|
||||
1. Focus ONLY on this directory's specific context
|
||||
2. NEVER repeat parent AGENTS.md content
|
||||
3. Shorter is better - 30-80 lines max
|
||||
4. Telegraphic style - sacrifice grammar
|
||||
|
||||
REQUIRED SECTIONS:
|
||||
- OVERVIEW (1 line: what this directory does)
|
||||
- STRUCTURE (only if >5 subdirs)
|
||||
- WHERE TO LOOK (directory-specific tasks)
|
||||
- CONVENTIONS (only if DIFFERENT from root)
|
||||
- ANTI-PATTERNS (directory-specific only)
|
||||
|
||||
OUTPUT: Write to \${loc.path}/AGENTS.md
|
||||
\\\`
|
||||
})
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**Wait for all agents. Mark "p4-subdirs" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Review & Deduplicate
|
||||
|
||||
**Mark "p5-review" as in_progress.**
|
||||
|
||||
### Validation Checklist
|
||||
|
||||
For EACH generated AGENTS.md:
|
||||
|
||||
| Check | Action if Fail |
|
||||
|-------|----------------|
|
||||
| Contains generic advice | REMOVE the line |
|
||||
| Repeats parent content | REMOVE the line |
|
||||
| Missing required section | ADD it |
|
||||
| Over 150 lines (root) / 80 lines (subdir) | TRIM |
|
||||
| Verbose explanations | REWRITE telegraphic |
|
||||
|
||||
### Cross-Reference Validation
|
||||
|
||||
\`\`\`
|
||||
For each child AGENTS.md:
|
||||
For each line in child:
|
||||
If similar line exists in parent:
|
||||
REMOVE from child (parent already covers)
|
||||
\`\`\`
|
||||
|
||||
**Mark "p5-review" as completed.**
|
||||
|
||||
---
|
||||
|
||||
## Final Report
|
||||
|
||||
\`\`\`
|
||||
=== init-deep Complete ===
|
||||
|
||||
Files Generated:
|
||||
✓ ./AGENTS.md (root, {N} lines)
|
||||
✓ ./src/hooks/AGENTS.md ({N} lines)
|
||||
✓ ./src/tools/AGENTS.md ({N} lines)
|
||||
|
||||
Directories Analyzed: {N}
|
||||
AGENTS.md Created: {N}
|
||||
Total Lines: {N}
|
||||
|
||||
Hierarchy:
|
||||
./AGENTS.md
|
||||
├── src/hooks/AGENTS.md
|
||||
└── src/tools/AGENTS.md
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns for THIS Command
|
||||
|
||||
- **Over-documenting**: Not every directory needs AGENTS.md
|
||||
- **Redundancy**: Child must NOT repeat parent
|
||||
- **Generic content**: Remove anything that applies to ALL projects
|
||||
- **Sequential execution**: MUST use parallel agents
|
||||
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
|
||||
- **Verbose style**: "This directory contains..." → just list it
|
||||
- **Ignoring LSP**: If LSP available, USE IT - semantic analysis > text grep
|
||||
- **LSP without fallback**: Always have explore agent backup if LSP unavailable
|
||||
- **Over-referencing**: Don't trace refs for EVERY symbol - focus on exports only`
|
||||
38
src/features/builtin-commands/templates/ralph-loop.ts
Normal file
38
src/features/builtin-commands/templates/ralph-loop.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-referential development loop that runs until task completion.
|
||||
|
||||
## How Ralph Loop Works
|
||||
|
||||
1. You will work on the task continuously
|
||||
2. When you believe the task is FULLY complete, output: \`<promise>{{COMPLETION_PROMISE}}</promise>\`
|
||||
3. If you don't output the promise, the loop will automatically inject another prompt to continue
|
||||
4. Maximum iterations: Configurable (default 100)
|
||||
|
||||
## Rules
|
||||
|
||||
- Focus on completing the task fully, not partially
|
||||
- Don't output the completion promise until the task is truly done
|
||||
- Each iteration should make meaningful progress toward the goal
|
||||
- If stuck, try different approaches
|
||||
- Use todos to track your progress
|
||||
|
||||
## Exit Conditions
|
||||
|
||||
1. **Completion**: Output \`<promise>DONE</promise>\` (or custom promise text) when fully complete
|
||||
2. **Max Iterations**: Loop stops automatically at limit
|
||||
3. **Cancel**: User runs \`/cancel-ralph\` command
|
||||
|
||||
## Your Task
|
||||
|
||||
Parse the arguments below and begin working on the task. The format is:
|
||||
\`"task description" [--completion-promise=TEXT] [--max-iterations=N]\`
|
||||
|
||||
Default completion promise is "DONE" and default max iterations is 100.`
|
||||
|
||||
export const CANCEL_RALPH_TEMPLATE = `Cancel the currently active Ralph Loop.
|
||||
|
||||
This will:
|
||||
1. Stop the loop from continuing
|
||||
2. Clear the loop state file
|
||||
3. Allow the session to end normally
|
||||
|
||||
Check if a loop is active and cancel it. Inform the user of the result.`
|
||||
9
src/features/builtin-commands/types.ts
Normal file
9
src/features/builtin-commands/types.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
|
||||
|
||||
export interface BuiltinCommandConfig {
|
||||
disabled_commands?: BuiltinCommandName[]
|
||||
}
|
||||
|
||||
export type BuiltinCommands = Record<string, CommandDefinition>
|
||||
2
src/features/builtin-skills/index.ts
Normal file
2
src/features/builtin-skills/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { createBuiltinSkills } from "./skills"
|
||||
5
src/features/builtin-skills/skills.ts
Normal file
5
src/features/builtin-skills/skills.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { BuiltinSkill } from "./types"
|
||||
|
||||
export function createBuiltinSkills(): BuiltinSkill[] {
|
||||
return []
|
||||
}
|
||||
13
src/features/builtin-skills/types.ts
Normal file
13
src/features/builtin-skills/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface BuiltinSkill {
|
||||
name: string
|
||||
description: string
|
||||
template: string
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, unknown>
|
||||
allowedTools?: string[]
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
}
|
||||
@@ -9,11 +9,12 @@ import { log } from "../../shared/logger"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { transformMcpServer } from "../claude-code-mcp-loader/transformer"
|
||||
import type { CommandDefinition, CommandFrontmatter } from "../claude-code-command-loader/types"
|
||||
import type { SkillMetadata } from "../claude-code-skill-loader/types"
|
||||
import type { SkillMetadata } from "../opencode-skill-loader/types"
|
||||
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
|
||||
import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types"
|
||||
import type {
|
||||
InstalledPluginsDatabase,
|
||||
PluginInstallation,
|
||||
PluginManifest,
|
||||
LoadedPlugin,
|
||||
PluginLoadResult,
|
||||
@@ -134,6 +135,15 @@ function isPluginEnabled(
|
||||
return true
|
||||
}
|
||||
|
||||
function extractPluginEntries(
|
||||
db: InstalledPluginsDatabase
|
||||
): Array<[string, PluginInstallation | undefined]> {
|
||||
if (db.version === 1) {
|
||||
return Object.entries(db.plugins).map(([key, installation]) => [key, installation])
|
||||
}
|
||||
return Object.entries(db.plugins).map(([key, installations]) => [key, installations[0]])
|
||||
}
|
||||
|
||||
export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginLoadResult {
|
||||
const db = loadInstalledPlugins()
|
||||
const settings = loadClaudeSettings()
|
||||
@@ -147,15 +157,14 @@ export function discoverInstalledPlugins(options?: PluginLoaderOptions): PluginL
|
||||
const settingsEnabledPlugins = settings?.enabledPlugins
|
||||
const overrideEnabledPlugins = options?.enabledPluginsOverride
|
||||
|
||||
for (const [pluginKey, installations] of Object.entries(db.plugins)) {
|
||||
if (!installations || installations.length === 0) continue
|
||||
for (const [pluginKey, installation] of extractPluginEntries(db)) {
|
||||
if (!installation) continue
|
||||
|
||||
if (!isPluginEnabled(pluginKey, settingsEnabledPlugins, overrideEnabledPlugins)) {
|
||||
log(`Plugin disabled: ${pluginKey}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const installation = installations[0]
|
||||
const { installPath, scope, version } = installation
|
||||
|
||||
if (!existsSync(installPath)) {
|
||||
|
||||
@@ -20,14 +20,29 @@ export interface PluginInstallation {
|
||||
isLocal?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins database v1 (legacy)
|
||||
* plugins stored as direct objects
|
||||
*/
|
||||
export interface InstalledPluginsDatabaseV1 {
|
||||
version: 1
|
||||
plugins: Record<string, PluginInstallation>
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins database v2 (current)
|
||||
* plugins stored as arrays
|
||||
*/
|
||||
export interface InstalledPluginsDatabaseV2 {
|
||||
version: 2
|
||||
plugins: Record<string, PluginInstallation[]>
|
||||
}
|
||||
|
||||
/**
|
||||
* Installed plugins database structure
|
||||
* Located at ~/.claude/plugins/installed_plugins.json
|
||||
*/
|
||||
export interface InstalledPluginsDatabase {
|
||||
version: number
|
||||
plugins: Record<string, PluginInstallation[]>
|
||||
}
|
||||
export type InstalledPluginsDatabase = InstalledPluginsDatabaseV1 | InstalledPluginsDatabaseV2
|
||||
|
||||
/**
|
||||
* Plugin author information
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
|
||||
|
||||
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: LoadedSkillAsCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(skillsDir, entry.name)
|
||||
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||
|
||||
const resolvedPath = resolveSymlink(skillPath)
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
if (!existsSync(skillMdPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
|
||||
const skillName = data.name || entry.name
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model),
|
||||
}
|
||||
|
||||
skills.push({
|
||||
name: skillName,
|
||||
path: resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
|
||||
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(projectSkillsDir, "project")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
|
||||
export type SkillScope = "user" | "project"
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string
|
||||
description: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LoadedSkillAsCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: SkillScope
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
export * from "./merger"
|
||||
246
src/features/opencode-skill-loader/loader.ts
Normal file
246
src/features/opencode-skill-loader/loader.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { homedir } from "os"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
|
||||
/**
|
||||
* Load a skill from a markdown file path.
|
||||
*
|
||||
* @param skillPath - Path to the skill file (SKILL.md or {name}.md)
|
||||
* @param resolvedPath - Directory for file reference resolution (@path references)
|
||||
* @param defaultName - Fallback name if not specified in frontmatter
|
||||
* @param scope - Source scope for priority ordering
|
||||
*/
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function loadSkillFromPath(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): LoadedSkill | null {
|
||||
try {
|
||||
const content = readFileSync(skillPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
path: skillPath,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
license: data.license,
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from a directory, supporting BOTH patterns:
|
||||
* - Directory with SKILL.md: skill-name/SKILL.md
|
||||
* - Directory with {SKILLNAME}.md: skill-name/{SKILLNAME}.md
|
||||
* - Direct markdown file: skill-name.md
|
||||
*/
|
||||
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[] {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: LoadedSkill[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
if (existsSync(skillMdPath)) {
|
||||
const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
}
|
||||
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
if (existsSync(namedSkillMdPath)) {
|
||||
const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const skill of skills) {
|
||||
result[skill.name] = skill.definition
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from Claude Code user directory (~/.claude/skills/)
|
||||
*/
|
||||
export function loadUserSkills(): Record<string, CommandDefinition> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from Claude Code project directory (.claude/skills/)
|
||||
*/
|
||||
export function loadProjectSkills(): Record<string, CommandDefinition> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(projectSkillsDir, "project")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from OpenCode global directory (~/.config/opencode/skill/)
|
||||
*/
|
||||
export function loadOpencodeGlobalSkills(): Record<string, CommandDefinition> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from OpenCode project directory (.opencode/skill/)
|
||||
*/
|
||||
export function loadOpencodeProjectSkills(): Record<string, CommandDefinition> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover all skills from all sources with priority ordering.
|
||||
* Priority order: opencode-project > project > opencode > user
|
||||
*
|
||||
* @returns Array of LoadedSkill objects for use in slashcommand discovery
|
||||
*/
|
||||
export function discoverAllSkills(): LoadedSkill[] {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
const projectDir = join(process.cwd(), ".claude", "skills")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
|
||||
const userDir = join(getClaudeConfigDir(), "skills")
|
||||
|
||||
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
const projectSkills = loadSkillsFromDir(projectDir, "project")
|
||||
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
|
||||
const userSkills = loadSkillsFromDir(userDir, "user")
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
}
|
||||
|
||||
export interface DiscoverSkillsOptions {
|
||||
includeClaudeCodePaths?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover skills with optional filtering.
|
||||
* When includeClaudeCodePaths is false, only loads from OpenCode paths.
|
||||
*/
|
||||
export function discoverSkills(options: DiscoverSkillsOptions = {}): LoadedSkill[] {
|
||||
const { includeClaudeCodePaths = true } = options
|
||||
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
|
||||
|
||||
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
|
||||
|
||||
if (!includeClaudeCodePaths) {
|
||||
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
|
||||
}
|
||||
|
||||
const projectDir = join(process.cwd(), ".claude", "skills")
|
||||
const userDir = join(getClaudeConfigDir(), "skills")
|
||||
|
||||
const projectSkills = loadSkillsFromDir(projectDir, "project")
|
||||
const userSkills = loadSkillsFromDir(userDir, "user")
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a skill by name from all available sources.
|
||||
*/
|
||||
export function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): LoadedSkill | undefined {
|
||||
const skills = discoverSkills(options)
|
||||
return skills.find(s => s.name === name)
|
||||
}
|
||||
|
||||
export function discoverUserClaudeSkills(): LoadedSkill[] {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDir(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export function discoverProjectClaudeSkills(): LoadedSkill[] {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDir(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export function discoverOpencodeGlobalSkills(): LoadedSkill[] {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export function discoverOpencodeProjectSkills(): LoadedSkill[] {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
266
src/features/opencode-skill-loader/merger.ts
Normal file
266
src/features/opencode-skill-loader/merger.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import type { LoadedSkill, SkillScope, SkillMetadata } from "./types"
|
||||
import type { SkillsConfig, SkillDefinition } from "../../config/schema"
|
||||
import type { BuiltinSkill } from "../builtin-skills/types"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import { readFileSync, existsSync } from "fs"
|
||||
import { dirname, resolve, isAbsolute } from "path"
|
||||
import { homedir } from "os"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { deepMerge } from "../../shared/deep-merge"
|
||||
|
||||
const SCOPE_PRIORITY: Record<SkillScope, number> = {
|
||||
builtin: 1,
|
||||
config: 2,
|
||||
user: 3,
|
||||
opencode: 4,
|
||||
project: 5,
|
||||
"opencode-project": 6,
|
||||
}
|
||||
|
||||
function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
|
||||
const definition: CommandDefinition = {
|
||||
name: builtin.name,
|
||||
description: `(builtin - Skill) ${builtin.description}`,
|
||||
template: builtin.template,
|
||||
model: builtin.model,
|
||||
agent: builtin.agent,
|
||||
subtask: builtin.subtask,
|
||||
argumentHint: builtin.argumentHint,
|
||||
}
|
||||
|
||||
return {
|
||||
name: builtin.name,
|
||||
definition,
|
||||
scope: "builtin",
|
||||
license: builtin.license,
|
||||
compatibility: builtin.compatibility,
|
||||
metadata: builtin.metadata as Record<string, string> | undefined,
|
||||
allowedTools: builtin.allowedTools,
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFilePath(from: string, configDir?: string): string {
|
||||
let filePath = from
|
||||
|
||||
if (filePath.startsWith("{file:") && filePath.endsWith("}")) {
|
||||
filePath = filePath.slice(6, -1)
|
||||
}
|
||||
|
||||
if (filePath.startsWith("~/")) {
|
||||
return resolve(homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
if (isAbsolute(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
|
||||
const baseDir = configDir || process.cwd()
|
||||
return resolve(baseDir, filePath)
|
||||
}
|
||||
|
||||
function loadSkillFromFile(filePath: string): { template: string; metadata: SkillMetadata } | null {
|
||||
try {
|
||||
if (!existsSync(filePath)) return null
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
return { template: body, metadata: data }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function configEntryToLoaded(
|
||||
name: string,
|
||||
entry: SkillDefinition,
|
||||
configDir?: string
|
||||
): LoadedSkill | null {
|
||||
let template = entry.template || ""
|
||||
let fileMetadata: SkillMetadata = {}
|
||||
|
||||
if (entry.from) {
|
||||
const filePath = resolveFilePath(entry.from, configDir)
|
||||
const loaded = loadSkillFromFile(filePath)
|
||||
if (loaded) {
|
||||
template = loaded.template
|
||||
fileMetadata = loaded.metadata
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
if (!template && !entry.from) {
|
||||
return null
|
||||
}
|
||||
|
||||
const description = entry.description || fileMetadata.description || ""
|
||||
const resolvedPath = entry.from ? dirname(resolveFilePath(entry.from, configDir)) : configDir || process.cwd()
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${template.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name,
|
||||
description: `(config - Skill) ${description}`,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(entry.model || fileMetadata.model, "opencode"),
|
||||
agent: entry.agent || fileMetadata.agent,
|
||||
subtask: entry.subtask ?? fileMetadata.subtask,
|
||||
argumentHint: entry["argument-hint"] || fileMetadata["argument-hint"],
|
||||
}
|
||||
|
||||
const allowedTools = entry["allowed-tools"] ||
|
||||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
|
||||
|
||||
return {
|
||||
name,
|
||||
path: entry.from ? resolveFilePath(entry.from, configDir) : undefined,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope: "config",
|
||||
license: entry.license || fileMetadata.license,
|
||||
compatibility: entry.compatibility || fileMetadata.compatibility,
|
||||
metadata: entry.metadata as Record<string, string> | undefined || fileMetadata.metadata,
|
||||
allowedTools,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConfig(config: SkillsConfig | undefined): {
|
||||
sources: Array<string | { path: string; recursive?: boolean; glob?: string }>
|
||||
enable: string[]
|
||||
disable: string[]
|
||||
entries: Record<string, boolean | SkillDefinition>
|
||||
} {
|
||||
if (!config) {
|
||||
return { sources: [], enable: [], disable: [], entries: {} }
|
||||
}
|
||||
|
||||
if (Array.isArray(config)) {
|
||||
return { sources: [], enable: config, disable: [], entries: {} }
|
||||
}
|
||||
|
||||
const { sources = [], enable = [], disable = [], ...entries } = config
|
||||
return { sources, enable, disable, entries }
|
||||
}
|
||||
|
||||
function mergeSkillDefinitions(base: LoadedSkill, patch: SkillDefinition): LoadedSkill {
|
||||
const mergedMetadata = base.metadata || patch.metadata
|
||||
? deepMerge(base.metadata || {}, (patch.metadata as Record<string, string>) || {})
|
||||
: undefined
|
||||
|
||||
const mergedTools = base.allowedTools || patch["allowed-tools"]
|
||||
? [...(base.allowedTools || []), ...(patch["allowed-tools"] || [])]
|
||||
: undefined
|
||||
|
||||
const description = patch.description || base.definition.description?.replace(/^\([^)]+\) /, "")
|
||||
|
||||
return {
|
||||
...base,
|
||||
definition: {
|
||||
...base.definition,
|
||||
description: `(${base.scope} - Skill) ${description}`,
|
||||
model: patch.model || base.definition.model,
|
||||
agent: patch.agent || base.definition.agent,
|
||||
subtask: patch.subtask ?? base.definition.subtask,
|
||||
argumentHint: patch["argument-hint"] || base.definition.argumentHint,
|
||||
},
|
||||
license: patch.license || base.license,
|
||||
compatibility: patch.compatibility || base.compatibility,
|
||||
metadata: mergedMetadata as Record<string, string> | undefined,
|
||||
allowedTools: mergedTools ? [...new Set(mergedTools)] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export interface MergeSkillsOptions {
|
||||
configDir?: string
|
||||
}
|
||||
|
||||
export function mergeSkills(
|
||||
builtinSkills: BuiltinSkill[],
|
||||
config: SkillsConfig | undefined,
|
||||
userClaudeSkills: LoadedSkill[],
|
||||
userOpencodeSkills: LoadedSkill[],
|
||||
projectClaudeSkills: LoadedSkill[],
|
||||
projectOpencodeSkills: LoadedSkill[],
|
||||
options: MergeSkillsOptions = {}
|
||||
): LoadedSkill[] {
|
||||
const skillMap = new Map<string, LoadedSkill>()
|
||||
|
||||
for (const builtin of builtinSkills) {
|
||||
const loaded = builtinToLoaded(builtin)
|
||||
skillMap.set(loaded.name, loaded)
|
||||
}
|
||||
|
||||
const normalizedConfig = normalizeConfig(config)
|
||||
|
||||
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
|
||||
if (entry === false) continue
|
||||
if (entry === true) continue
|
||||
|
||||
if (entry.disable) continue
|
||||
|
||||
const loaded = configEntryToLoaded(name, entry, options.configDir)
|
||||
if (loaded) {
|
||||
const existing = skillMap.get(name)
|
||||
if (existing && !entry.template && !entry.from) {
|
||||
skillMap.set(name, mergeSkillDefinitions(existing, entry))
|
||||
} else {
|
||||
skillMap.set(name, loaded)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileSystemSkills = [
|
||||
...userClaudeSkills,
|
||||
...userOpencodeSkills,
|
||||
...projectClaudeSkills,
|
||||
...projectOpencodeSkills,
|
||||
]
|
||||
|
||||
for (const skill of fileSystemSkills) {
|
||||
const existing = skillMap.get(skill.name)
|
||||
if (!existing || SCOPE_PRIORITY[skill.scope] > SCOPE_PRIORITY[existing.scope]) {
|
||||
skillMap.set(skill.name, skill)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, entry] of Object.entries(normalizedConfig.entries)) {
|
||||
if (entry === true) continue
|
||||
if (entry === false) {
|
||||
skillMap.delete(name)
|
||||
continue
|
||||
}
|
||||
if (entry.disable) {
|
||||
skillMap.delete(name)
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = skillMap.get(name)
|
||||
if (existing && !entry.template && !entry.from) {
|
||||
skillMap.set(name, mergeSkillDefinitions(existing, entry))
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of normalizedConfig.disable) {
|
||||
skillMap.delete(name)
|
||||
}
|
||||
|
||||
if (normalizedConfig.enable.length > 0) {
|
||||
const enableSet = new Set(normalizedConfig.enable)
|
||||
for (const name of skillMap.keys()) {
|
||||
if (!enableSet.has(name)) {
|
||||
skillMap.delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(skillMap.values())
|
||||
}
|
||||
28
src/features/opencode-skill-loader/types.ts
Normal file
28
src/features/opencode-skill-loader/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
|
||||
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface SkillMetadata {
|
||||
name?: string
|
||||
description?: string
|
||||
model?: string
|
||||
"argument-hint"?: string
|
||||
agent?: string
|
||||
subtask?: boolean
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
"allowed-tools"?: string
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
path?: string
|
||||
resolvedPath?: string
|
||||
definition: CommandDefinition
|
||||
scope: SkillScope
|
||||
license?: string
|
||||
compatibility?: string
|
||||
metadata?: Record<string, string>
|
||||
allowedTools?: string[]
|
||||
}
|
||||
@@ -9,7 +9,7 @@ Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce ru
|
||||
```
|
||||
hooks/
|
||||
├── agent-usage-reminder/ # Remind to use specialized agents
|
||||
├── anthropic-auto-compact/ # Auto-compact Claude at token limit
|
||||
├── anthropic-context-window-limit-recovery/ # Auto-compact Claude at token limit
|
||||
├── auto-update-checker/ # Version update notifications
|
||||
├── background-notification/ # OS notify on background task complete
|
||||
├── claude-code-hooks/ # Claude Code settings.json integration
|
||||
@@ -27,6 +27,7 @@ hooks/
|
||||
├── rules-injector/ # Conditional rules from .claude/rules/
|
||||
├── session-recovery/ # Recover from session errors
|
||||
├── think-mode/ # Auto-detect thinking triggers
|
||||
├── thinking-block-validator/ # Validate thinking blocks in messages
|
||||
├── context-window-monitor.ts # Monitor context usage (standalone)
|
||||
├── empty-task-response-detector.ts
|
||||
├── session-notification.ts # OS notify on idle (standalone)
|
||||
@@ -39,7 +40,7 @@ hooks/
|
||||
| Category | Hooks | Purpose |
|
||||
|----------|-------|---------|
|
||||
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
|
||||
| Session Management | session-recovery, anthropic-auto-compact, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
|
||||
| Session Management | session-recovery, anthropic-context-window-limit-recovery, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
|
||||
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
|
||||
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
|
||||
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const AGENT_USAGE_REMINDER_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"agent-usage-reminder",
|
||||
|
||||
@@ -151,6 +151,7 @@ describe("executeCompact lock management", () => {
|
||||
truncate_all_tool_outputs: false,
|
||||
aggressive_truncation: true,
|
||||
}
|
||||
const dcpForCompaction = true
|
||||
|
||||
// #when: Execute compaction with experimental flag
|
||||
await executeCompact(
|
||||
@@ -160,6 +161,7 @@ describe("executeCompact lock management", () => {
|
||||
mockClient,
|
||||
directory,
|
||||
experimental,
|
||||
dcpForCompaction,
|
||||
)
|
||||
|
||||
// #then: Lock should be cleared even on early return
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
} from "../session-recovery/storage";
|
||||
import { log } from "../../shared/logger";
|
||||
|
||||
const PLACEHOLDER_TEXT = "[user interrupted]";
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: {
|
||||
@@ -103,6 +105,36 @@ function getOrCreateDcpState(
|
||||
return state;
|
||||
}
|
||||
|
||||
function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
|
||||
const emptyMessageIds = findEmptyMessages(sessionID);
|
||||
if (emptyMessageIds.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let fixedCount = 0;
|
||||
for (const messageID of emptyMessageIds) {
|
||||
const replaced = replaceEmptyTextParts(messageID, PLACEHOLDER_TEXT);
|
||||
if (replaced) {
|
||||
fixedCount++;
|
||||
} else {
|
||||
const injected = injectTextPart(sessionID, messageID, PLACEHOLDER_TEXT);
|
||||
if (injected) {
|
||||
fixedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fixedCount > 0) {
|
||||
log("[auto-compact] pre-summarize sanitization fixed empty messages", {
|
||||
sessionID,
|
||||
fixedCount,
|
||||
totalEmpty: emptyMessageIds.length,
|
||||
});
|
||||
}
|
||||
|
||||
return fixedCount;
|
||||
}
|
||||
|
||||
async function getLastMessagePair(
|
||||
sessionID: string,
|
||||
client: Client,
|
||||
@@ -305,6 +337,7 @@ export async function executeCompact(
|
||||
client: any,
|
||||
directory: string,
|
||||
experimental?: ExperimentalConfig,
|
||||
dcpForCompaction?: boolean,
|
||||
): Promise<void> {
|
||||
if (autoCompactState.compactionInProgress.has(sessionID)) {
|
||||
await (client as Client).tui
|
||||
@@ -326,6 +359,104 @@ export async function executeCompact(
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID);
|
||||
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
|
||||
|
||||
// DCP FIRST - run before any other recovery attempts when token limit exceeded (controlled by dcp-for-compaction hook)
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (
|
||||
dcpForCompaction !== false &&
|
||||
!dcpState.attempted &&
|
||||
errorData?.currentTokens &&
|
||||
errorData?.maxTokens &&
|
||||
errorData.currentTokens > errorData.maxTokens
|
||||
) {
|
||||
dcpState.attempted = true;
|
||||
log("[auto-compact] DCP triggered FIRST on token limit error", {
|
||||
sessionID,
|
||||
currentTokens: errorData.currentTokens,
|
||||
maxTokens: errorData.maxTokens,
|
||||
});
|
||||
|
||||
const dcpConfig = experimental?.dynamic_context_pruning ?? {
|
||||
enabled: true,
|
||||
notification: "detailed" as const,
|
||||
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
|
||||
};
|
||||
|
||||
try {
|
||||
const pruningResult = await executeDynamicContextPruning(
|
||||
sessionID,
|
||||
dcpConfig,
|
||||
client
|
||||
);
|
||||
|
||||
if (pruningResult.itemsPruned > 0) {
|
||||
dcpState.itemsPruned = pruningResult.itemsPruned;
|
||||
log("[auto-compact] DCP successful, proceeding to compaction", {
|
||||
itemsPruned: pruningResult.itemsPruned,
|
||||
tokensSaved: pruningResult.totalTokensSaved,
|
||||
});
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Running compaction...`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// After DCP, immediately try summarize
|
||||
const providerID = msg.providerID as string | undefined;
|
||||
const modelID = msg.modelID as string | undefined;
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Summarizing session after DCP...",
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
clearSessionState(autoCompactState, sessionID);
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
} catch {}
|
||||
}, 500);
|
||||
return;
|
||||
} catch (summarizeError) {
|
||||
log("[auto-compact] summarize after DCP failed, continuing recovery", {
|
||||
error: String(summarizeError),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log("[auto-compact] DCP did not prune any items", { sessionID });
|
||||
}
|
||||
} catch (error) {
|
||||
log("[auto-compact] DCP failed", { error: String(error) });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
experimental?.aggressive_truncation &&
|
||||
errorData?.currentTokens &&
|
||||
@@ -488,6 +619,7 @@ export async function executeCompact(
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
dcpForCompaction,
|
||||
);
|
||||
}, 500);
|
||||
return;
|
||||
@@ -523,6 +655,8 @@ export async function executeCompact(
|
||||
|
||||
if (providerID && modelID) {
|
||||
try {
|
||||
sanitizeEmptyMessagesBeforeSummarize(sessionID);
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
@@ -564,6 +698,7 @@ export async function executeCompact(
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
dcpForCompaction,
|
||||
);
|
||||
}, cappedDelay);
|
||||
return;
|
||||
@@ -582,67 +717,6 @@ export async function executeCompact(
|
||||
}
|
||||
}
|
||||
|
||||
// Try DCP after summarize fails - only once per compaction cycle
|
||||
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
|
||||
if (experimental?.dcp_on_compaction_failure && !dcpState.attempted) {
|
||||
dcpState.attempted = true;
|
||||
log("[auto-compact] attempting DCP after summarize failed", { sessionID });
|
||||
|
||||
const dcpConfig = experimental.dynamic_context_pruning ?? {
|
||||
enabled: true,
|
||||
notification: "detailed" as const,
|
||||
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
|
||||
};
|
||||
|
||||
try {
|
||||
const pruningResult = await executeDynamicContextPruning(
|
||||
sessionID,
|
||||
dcpConfig,
|
||||
client
|
||||
);
|
||||
|
||||
if (pruningResult.itemsPruned > 0) {
|
||||
dcpState.itemsPruned = pruningResult.itemsPruned;
|
||||
log("[auto-compact] DCP successful, retrying compaction", {
|
||||
itemsPruned: pruningResult.itemsPruned,
|
||||
tokensSaved: pruningResult.totalTokensSaved,
|
||||
});
|
||||
|
||||
await (client as Client).tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Dynamic Context Pruning",
|
||||
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Retrying compaction...`,
|
||||
variant: "success",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
// Reset retry state to allow compaction to retry summarize
|
||||
retryState.attempt = 0;
|
||||
|
||||
setTimeout(() => {
|
||||
executeCompact(
|
||||
sessionID,
|
||||
msg,
|
||||
autoCompactState,
|
||||
client,
|
||||
directory,
|
||||
experimental,
|
||||
);
|
||||
}, 500);
|
||||
return;
|
||||
} else {
|
||||
log("[auto-compact] DCP did not prune any items, continuing to revert", { sessionID });
|
||||
}
|
||||
} catch (error) {
|
||||
log("[auto-compact] DCP failed, continuing to revert", {
|
||||
error: String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
|
||||
|
||||
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
|
||||
@@ -5,11 +5,12 @@ import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface AnthropicAutoCompactOptions {
|
||||
export interface AnthropicContextWindowLimitRecoveryOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
dcpForCompaction?: boolean
|
||||
}
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
function createRecoveryState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
@@ -22,9 +23,10 @@ function createAutoCompactState(): AutoCompactState {
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: AnthropicAutoCompactOptions) {
|
||||
const autoCompactState = createAutoCompactState()
|
||||
export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput, options?: AnthropicContextWindowLimitRecoveryOptions) {
|
||||
const autoCompactState = createRecoveryState()
|
||||
const experimental = options?.experimental
|
||||
const dcpForCompaction = options?.dcpForCompaction
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
@@ -81,7 +83,8 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: Anthr
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental
|
||||
experimental,
|
||||
dcpForCompaction
|
||||
)
|
||||
}, 300)
|
||||
}
|
||||
@@ -140,7 +143,8 @@ export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: Anthr
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory,
|
||||
experimental
|
||||
experimental,
|
||||
dcpForCompaction
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const TOKEN_LIMIT_KEYWORDS = [
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
@@ -114,9 +115,10 @@ export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitErr
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
// Greedy match to last } for nested JSON
|
||||
/data:\s*(\{[\s\S]*\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*\})/,
|
||||
/(\{[\s\S]*"error"[\s\S]*\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
@@ -3,19 +3,13 @@ import { join } from "node:path"
|
||||
import type { PruningState, ToolCallSignature } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface DeduplicationConfig {
|
||||
enabled: boolean
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
const MESSAGE_STORAGE = join(
|
||||
process.env.HOME || process.env.USERPROFILE || "",
|
||||
".config",
|
||||
"opencode",
|
||||
"sessions"
|
||||
)
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
@@ -3,6 +3,7 @@ import { join } from "node:path"
|
||||
import type { PruningState, ErroredToolCall } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface PurgeErrorsConfig {
|
||||
enabled: boolean
|
||||
@@ -10,13 +11,6 @@ export interface PurgeErrorsConfig {
|
||||
protectedTools?: string[]
|
||||
}
|
||||
|
||||
const MESSAGE_STORAGE = join(
|
||||
process.env.HOME || process.env.USERPROFILE || "",
|
||||
".config",
|
||||
"opencode",
|
||||
"sessions"
|
||||
)
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
@@ -3,13 +3,7 @@ import { join } from "node:path"
|
||||
import type { PruningState } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const MESSAGE_STORAGE = join(
|
||||
process.env.HOME || process.env.USERPROFILE || "",
|
||||
".config",
|
||||
"opencode",
|
||||
"sessions"
|
||||
)
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
@@ -3,19 +3,13 @@ import { join } from "node:path"
|
||||
import type { PruningState, FileOperation } from "./pruning-types"
|
||||
import { estimateTokens } from "./pruning-types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
|
||||
export interface SupersedeWritesConfig {
|
||||
enabled: boolean
|
||||
aggressive: boolean
|
||||
}
|
||||
|
||||
const MESSAGE_STORAGE = join(
|
||||
process.env.HOME || process.env.USERPROFILE || "",
|
||||
".config",
|
||||
"opencode",
|
||||
"sessions"
|
||||
)
|
||||
|
||||
interface ToolPart {
|
||||
type: string
|
||||
callID?: string
|
||||
@@ -1,19 +1,8 @@
|
||||
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
|
||||
let OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
|
||||
// Fix for macOS where xdg-basedir points to ~/Library/Application Support
|
||||
// but OpenCode (cli) uses ~/.local/share
|
||||
if (process.platform === "darwin" && !existsSync(OPENCODE_STORAGE)) {
|
||||
const localShare = join(homedir(), ".local", "share", "opencode", "storage")
|
||||
if (existsSync(localShare)) {
|
||||
OPENCODE_STORAGE = localShare
|
||||
}
|
||||
}
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
@@ -23,6 +23,19 @@ function getBinaryName(): string {
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
// Check cached binary first (safest path - no module resolution needed)
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// Guard against undefined import.meta.url (can happen on Windows during plugin loading)
|
||||
if (!import.meta.url) {
|
||||
debugLog("import.meta.url is undefined, skipping package resolution")
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
@@ -33,14 +46,8 @@ function findCommentCheckerPathSync(): string | null {
|
||||
debugLog("found binary in main package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
} catch (err) {
|
||||
debugLog("main package not installed or resolution failed:", err)
|
||||
}
|
||||
|
||||
debugLog("no binary found in known locations")
|
||||
@@ -142,8 +149,9 @@ export interface CheckResult {
|
||||
* Run comment-checker CLI with given input.
|
||||
* @param input Hook input to check
|
||||
* @param cliPath Optional explicit path to CLI binary
|
||||
* @param customPrompt Optional custom prompt to replace default warning message
|
||||
*/
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string): Promise<CheckResult> {
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string, customPrompt?: string): Promise<CheckResult> {
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
|
||||
|
||||
if (!binaryPath) {
|
||||
@@ -160,7 +168,12 @@ export async function runCommentChecker(input: HookInput, cliPath?: string): Pro
|
||||
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
|
||||
|
||||
try {
|
||||
const proc = spawn([binaryPath], {
|
||||
const args = [binaryPath]
|
||||
if (customPrompt) {
|
||||
args.push("--prompt", customPrompt)
|
||||
}
|
||||
|
||||
const proc = spawn(args, {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
|
||||
@@ -32,9 +32,16 @@ const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
|
||||
/**
|
||||
* Get the cache directory for oh-my-opencode binaries.
|
||||
* Follows XDG Base Directory Specification.
|
||||
* On Windows: Uses %LOCALAPPDATA% or %APPDATA% (Windows conventions)
|
||||
* On Unix: Follows XDG Base Directory Specification
|
||||
*/
|
||||
export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PendingCall } from "./types"
|
||||
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
|
||||
import type { CommentCheckerConfig } from "../../config/schema"
|
||||
|
||||
import * as fs from "fs"
|
||||
import { existsSync } from "fs"
|
||||
@@ -20,6 +21,7 @@ const pendingCalls = new Map<string, PendingCall>()
|
||||
const PENDING_CALL_TTL = 60_000
|
||||
|
||||
let cliPathPromise: Promise<string | null> | null = null
|
||||
let cleanupIntervalStarted = false
|
||||
|
||||
function cleanupOldPendingCalls(): void {
|
||||
const now = Date.now()
|
||||
@@ -30,10 +32,13 @@ function cleanupOldPendingCalls(): void {
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||
debugLog("createCommentCheckerHooks called", { config })
|
||||
|
||||
export function createCommentCheckerHooks() {
|
||||
debugLog("createCommentCheckerHooks called")
|
||||
if (!cleanupIntervalStarted) {
|
||||
cleanupIntervalStarted = true
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
}
|
||||
|
||||
// Start background CLI initialization (may trigger lazy download)
|
||||
startBackgroundInit()
|
||||
@@ -123,7 +128,7 @@ export function createCommentCheckerHooks() {
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
@@ -135,7 +140,8 @@ async function processWithCli(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
pendingCall: PendingCall,
|
||||
output: { output: string },
|
||||
cliPath: string
|
||||
cliPath: string,
|
||||
customPrompt?: string
|
||||
): Promise<void> {
|
||||
debugLog("using CLI mode with path:", cliPath)
|
||||
|
||||
@@ -154,7 +160,7 @@ async function processWithCli(
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCommentChecker(hookInput, cliPath)
|
||||
const result = await runCommentChecker(hookInput, cliPath, customPrompt)
|
||||
|
||||
if (result.hasComments && result.message) {
|
||||
debugLog("CLI detected comments, appending message")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const AGENTS_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-agents",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const README_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-readme",
|
||||
|
||||
@@ -42,8 +42,13 @@ export function createEmptyMessageSanitizerHook(): MessagesTransformHook {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.info.role === "user") continue
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i]
|
||||
const isLastMessage = i === messages.length - 1
|
||||
const isAssistant = message.info.role === "assistant"
|
||||
|
||||
// Skip final assistant message (allowed to be empty per API spec)
|
||||
if (isLastMessage && isAssistant) continue
|
||||
|
||||
const parts = message.parts
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export { createToolOutputTruncatorHook } from "./tool-output-truncator";
|
||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
|
||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||
export { createAnthropicAutoCompactHook, type AnthropicAutoCompactOptions } from "./anthropic-auto-compact";
|
||||
export { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
|
||||
export { createPreemptiveCompactionHook, type PreemptiveCompactionOptions, type SummarizeContext, type BeforeSummarizeCallback } from "./preemptive-compaction";
|
||||
export { createCompactionContextInjector } from "./compaction-context-injector";
|
||||
export { createThinkModeHook } from "./think-mode";
|
||||
@@ -22,3 +22,4 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env";
|
||||
export { createInteractiveBashSessionHook } from "./interactive-bash-session";
|
||||
export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const INTERACTIVE_BASH_SESSION_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"interactive-bash-session",
|
||||
|
||||
@@ -82,7 +82,7 @@ export function createPreemptiveCompactionHook(
|
||||
const experimental = options?.experimental
|
||||
const onBeforeSummarize = options?.onBeforeSummarize
|
||||
const getModelLimit = options?.getModelLimit
|
||||
const enabled = experimental?.preemptive_compaction !== false
|
||||
const enabled = experimental?.preemptive_compaction === true
|
||||
const threshold = experimental?.preemptive_compaction_threshold ?? DEFAULT_THRESHOLD
|
||||
|
||||
if (!enabled) {
|
||||
|
||||
5
src/hooks/ralph-loop/constants.ts
Normal file
5
src/hooks/ralph-loop/constants.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const HOOK_NAME = "ralph-loop"
|
||||
export const DEFAULT_STATE_FILE = ".sisyphus/ralph-loop.local.md"
|
||||
export const COMPLETION_TAG_PATTERN = /<promise>(.*?)<\/promise>/is
|
||||
export const DEFAULT_MAX_ITERATIONS = 100
|
||||
export const DEFAULT_COMPLETION_PROMISE = "DONE"
|
||||
388
src/hooks/ralph-loop/index.test.ts
Normal file
388
src/hooks/ralph-loop/index.test.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { tmpdir } from "node:os"
|
||||
import { createRalphLoopHook } from "./index"
|
||||
import { readState, writeState, clearState } from "./storage"
|
||||
import type { RalphLoopState } from "./types"
|
||||
|
||||
describe("ralph-loop", () => {
|
||||
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
|
||||
let promptCalls: Array<{ sessionID: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string; variant: string }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
prompt: async (opts: { path: { id: string }; body: { parts: Array<{ type: string; text: string }> } }) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
|
||||
toastCalls.push({
|
||||
title: opts.body.title,
|
||||
message: opts.body.message,
|
||||
variant: opts.body.variant,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: TEST_DIR,
|
||||
} as Parameters<typeof createRalphLoopHook>[0]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
|
||||
if (!existsSync(TEST_DIR)) {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
clearState(TEST_DIR)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearState(TEST_DIR)
|
||||
if (existsSync(TEST_DIR)) {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe("storage", () => {
|
||||
test("should write and read state correctly", () => {
|
||||
// #given - a state object
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build a REST API",
|
||||
session_id: "test-session-123",
|
||||
}
|
||||
|
||||
// #when - write and read state
|
||||
const writeSuccess = writeState(TEST_DIR, state)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - state should match
|
||||
expect(writeSuccess).toBe(true)
|
||||
expect(readResult).not.toBeNull()
|
||||
expect(readResult?.active).toBe(true)
|
||||
expect(readResult?.iteration).toBe(1)
|
||||
expect(readResult?.max_iterations).toBe(50)
|
||||
expect(readResult?.completion_promise).toBe("DONE")
|
||||
expect(readResult?.prompt).toBe("Build a REST API")
|
||||
expect(readResult?.session_id).toBe("test-session-123")
|
||||
})
|
||||
|
||||
test("should return null for non-existent state", () => {
|
||||
// #given - no state file exists
|
||||
// #when - read state
|
||||
const result = readState(TEST_DIR)
|
||||
|
||||
// #then - should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
test("should clear state correctly", () => {
|
||||
// #given - existing state
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Test prompt",
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// #when - clear state
|
||||
const clearSuccess = clearState(TEST_DIR)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - state should be cleared
|
||||
expect(clearSuccess).toBe(true)
|
||||
expect(readResult).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle multiline prompts", () => {
|
||||
// #given - state with multiline prompt
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations: 10,
|
||||
completion_promise: "FINISHED",
|
||||
started_at: "2025-12-30T02:00:00Z",
|
||||
prompt: "Build a feature\nwith multiple lines\nand requirements",
|
||||
}
|
||||
|
||||
// #when - write and read
|
||||
writeState(TEST_DIR, state)
|
||||
const readResult = readState(TEST_DIR)
|
||||
|
||||
// #then - multiline prompt preserved
|
||||
expect(readResult?.prompt).toBe("Build a feature\nwith multiple lines\nand requirements")
|
||||
})
|
||||
})
|
||||
|
||||
describe("hook", () => {
|
||||
test("should start loop and write state", () => {
|
||||
// #given - hook instance
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
// #when - start loop
|
||||
const success = hook.startLoop("session-123", "Build something", {
|
||||
maxIterations: 25,
|
||||
completionPromise: "FINISHED",
|
||||
})
|
||||
|
||||
// #then - state should be written
|
||||
expect(success).toBe(true)
|
||||
const state = hook.getState()
|
||||
expect(state?.active).toBe(true)
|
||||
expect(state?.iteration).toBe(1)
|
||||
expect(state?.max_iterations).toBe(25)
|
||||
expect(state?.completion_promise).toBe("FINISHED")
|
||||
expect(state?.prompt).toBe("Build something")
|
||||
expect(state?.session_id).toBe("session-123")
|
||||
})
|
||||
|
||||
test("should inject continuation when loop active and no completion detected", async () => {
|
||||
// #given - active loop state
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build a feature", { maxIterations: 10 })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - continuation should be injected
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-123")
|
||||
expect(promptCalls[0].text).toContain("RALPH LOOP")
|
||||
expect(promptCalls[0].text).toContain("Build a feature")
|
||||
expect(promptCalls[0].text).toContain("2/10")
|
||||
|
||||
// #then - iteration should be incremented
|
||||
const state = hook.getState()
|
||||
expect(state?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should stop loop when max iterations reached", async () => {
|
||||
// #given - loop at max iteration
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something", { maxIterations: 2 })
|
||||
|
||||
const state = hook.getState()!
|
||||
state.iteration = 2
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
|
||||
// #then - warning toast shown
|
||||
expect(toastCalls.length).toBe(1)
|
||||
expect(toastCalls[0].title).toBe("Ralph Loop Stopped")
|
||||
expect(toastCalls[0].variant).toBe("warning")
|
||||
|
||||
// #then - state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should cancel loop via cancelLoop", () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - cancel loop
|
||||
const success = hook.cancelLoop("session-123")
|
||||
|
||||
// #then - loop cancelled
|
||||
expect(success).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should not cancel loop for different session", () => {
|
||||
// #given - active loop for session-123
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - try to cancel for different session
|
||||
const success = hook.cancelLoop("session-456")
|
||||
|
||||
// #then - cancel should fail
|
||||
expect(success).toBe(false)
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
})
|
||||
|
||||
test("should skip injection during recovery", async () => {
|
||||
// #given - active loop and session in recovery
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session-123", error: new Error("test") },
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should clear state on session deletion", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - session deleted
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.deleted",
|
||||
properties: { info: { id: "session-123" } },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should not inject for different session than loop owner", async () => {
|
||||
// #given - loop owned by session-123
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #when - different session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should use default config values", () => {
|
||||
// #given - hook with config
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
config: {
|
||||
enabled: true,
|
||||
default_max_iterations: 200,
|
||||
},
|
||||
})
|
||||
|
||||
// #when - start loop without options
|
||||
hook.startLoop("session-123", "Test task")
|
||||
|
||||
// #then - should use config defaults
|
||||
const state = hook.getState()
|
||||
expect(state?.max_iterations).toBe(200)
|
||||
})
|
||||
|
||||
test("should not inject when no loop is active", async () => {
|
||||
// #given - no active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should detect completion promise and stop loop", async () => {
|
||||
// #given - active loop with transcript containing completion
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
|
||||
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "Task done <promise>COMPLETE</promise>" }))
|
||||
|
||||
// #when - session goes idle with transcript
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "session-123", transcriptPath },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop completed, no continuation
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should handle multiple iterations correctly", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build feature", { maxIterations: 5 })
|
||||
|
||||
// #when - multiple idle events
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - iteration incremented correctly
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
})
|
||||
|
||||
test("should include prompt and promise in continuation message", async () => {
|
||||
// #given - loop with specific prompt and promise
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Create a calculator app", {
|
||||
completionPromise: "CALCULATOR_DONE",
|
||||
maxIterations: 10,
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - continuation includes original task and promise
|
||||
expect(promptCalls[0].text).toContain("Create a calculator app")
|
||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||
})
|
||||
})
|
||||
})
|
||||
272
src/hooks/ralph-loop/index.ts
Normal file
272
src/hooks/ralph-loop/index.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
import { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||
import {
|
||||
HOOK_NAME,
|
||||
DEFAULT_MAX_ITERATIONS,
|
||||
DEFAULT_COMPLETION_PROMISE,
|
||||
} from "./constants"
|
||||
import type { RalphLoopState, RalphLoopOptions } from "./types"
|
||||
|
||||
export * from "./types"
|
||||
export * from "./constants"
|
||||
export { readState, writeState, clearState, incrementIteration } from "./storage"
|
||||
|
||||
interface SessionState {
|
||||
isRecovering?: boolean
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
|
||||
|
||||
Your previous attempt did not output the completion promise. Continue working on the task.
|
||||
|
||||
IMPORTANT:
|
||||
- Review your progress so far
|
||||
- Continue from where you left off
|
||||
- When FULLY complete, output: <promise>{{PROMISE}}</promise>
|
||||
- Do not stop until the task is truly done
|
||||
|
||||
Original task:
|
||||
{{PROMPT}}`
|
||||
|
||||
export interface RalphLoopHook {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
startLoop: (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
options?: { maxIterations?: number; completionPromise?: string }
|
||||
) => boolean
|
||||
cancelLoop: (sessionID: string) => boolean
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
): RalphLoopHook {
|
||||
const sessions = new Map<string, SessionState>()
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
if (!state) {
|
||||
state = {}
|
||||
sessions.set(sessionID, state)
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
function detectCompletionPromise(
|
||||
transcriptPath: string | undefined,
|
||||
promise: string
|
||||
): boolean {
|
||||
if (!transcriptPath) return false
|
||||
|
||||
try {
|
||||
if (!existsSync(transcriptPath)) return false
|
||||
|
||||
const content = readFileSync(transcriptPath, "utf-8")
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
return pattern.test(content)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
}
|
||||
|
||||
const startLoop = (
|
||||
sessionID: string,
|
||||
prompt: string,
|
||||
loopOptions?: { maxIterations?: number; completionPromise?: string }
|
||||
): boolean => {
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 1,
|
||||
max_iterations:
|
||||
loopOptions?.maxIterations ?? config?.default_max_iterations ?? DEFAULT_MAX_ITERATIONS,
|
||||
completion_promise: loopOptions?.completionPromise ?? DEFAULT_COMPLETION_PROMISE,
|
||||
started_at: new Date().toISOString(),
|
||||
prompt,
|
||||
session_id: sessionID,
|
||||
}
|
||||
|
||||
const success = writeState(ctx.directory, state, stateDir)
|
||||
if (success) {
|
||||
log(`[${HOOK_NAME}] Loop started`, {
|
||||
sessionID,
|
||||
maxIterations: state.max_iterations,
|
||||
completionPromise: state.completion_promise,
|
||||
})
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
const cancelLoop = (sessionID: string): boolean => {
|
||||
const state = readState(ctx.directory, stateDir)
|
||||
if (!state || state.session_id !== sessionID) {
|
||||
return false
|
||||
}
|
||||
|
||||
const success = clearState(ctx.directory, stateDir)
|
||||
if (success) {
|
||||
log(`[${HOOK_NAME}] Loop cancelled`, { sessionID, iteration: state.iteration })
|
||||
}
|
||||
return success
|
||||
}
|
||||
|
||||
const getState = (): RalphLoopState | null => {
|
||||
return readState(ctx.directory, stateDir)
|
||||
}
|
||||
|
||||
const event = async ({
|
||||
event,
|
||||
}: {
|
||||
event: { type: string; properties?: unknown }
|
||||
}): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const sessionState = getSessionState(sessionID)
|
||||
if (sessionState.isRecovering) {
|
||||
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const state = readState(ctx.directory, stateDir)
|
||||
if (!state || !state.active) {
|
||||
return
|
||||
}
|
||||
|
||||
if (state.session_id && state.session_id !== sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
const transcriptPath = props?.transcriptPath as string | undefined
|
||||
|
||||
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop Complete!",
|
||||
message: `Task completed after ${state.iteration} iteration(s)`,
|
||||
variant: "success",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (state.iteration >= state.max_iterations) {
|
||||
log(`[${HOOK_NAME}] Max iterations reached`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
max: state.max_iterations,
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop Stopped",
|
||||
message: `Max iterations (${state.max_iterations}) reached without completion`,
|
||||
variant: "warning",
|
||||
duration: 5000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const newState = incrementIteration(ctx.directory, stateDir)
|
||||
if (!newState) {
|
||||
log(`[${HOOK_NAME}] Failed to increment iteration`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Continuing loop`, {
|
||||
sessionID,
|
||||
iteration: newState.iteration,
|
||||
max: newState.max_iterations,
|
||||
})
|
||||
|
||||
const continuationPrompt = CONTINUATION_PROMPT.replace("{{ITERATION}}", String(newState.iteration))
|
||||
.replace("{{MAX}}", String(newState.max_iterations))
|
||||
.replace("{{PROMISE}}", newState.completion_promise)
|
||||
.replace("{{PROMPT}}", newState.prompt)
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ralph Loop",
|
||||
message: `Iteration ${newState.iteration}/${newState.max_iterations}`,
|
||||
variant: "info",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
try {
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [{ type: "text", text: continuationPrompt }],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to inject continuation`, {
|
||||
sessionID,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
const state = readState(ctx.directory, stateDir)
|
||||
if (state?.session_id === sessionInfo.id) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] Session deleted, loop cleared`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
sessions.delete(sessionInfo.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const sessionState = getSessionState(sessionID)
|
||||
sessionState.isRecovering = true
|
||||
setTimeout(() => {
|
||||
sessionState.isRecovering = false
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
startLoop,
|
||||
cancelLoop,
|
||||
getState,
|
||||
}
|
||||
}
|
||||
113
src/hooks/ralph-loop/storage.ts
Normal file
113
src/hooks/ralph-loop/storage.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync } from "node:fs"
|
||||
import { dirname, join } from "node:path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { RalphLoopState } from "./types"
|
||||
import { DEFAULT_STATE_FILE, DEFAULT_COMPLETION_PROMISE, DEFAULT_MAX_ITERATIONS } from "./constants"
|
||||
|
||||
export function getStateFilePath(directory: string, customPath?: string): string {
|
||||
return customPath
|
||||
? join(directory, customPath)
|
||||
: join(directory, DEFAULT_STATE_FILE)
|
||||
}
|
||||
|
||||
export function readState(directory: string, customPath?: string): RalphLoopState | null {
|
||||
const filePath = getStateFilePath(directory, customPath)
|
||||
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<Record<string, unknown>>(content)
|
||||
|
||||
const active = data.active
|
||||
const iteration = data.iteration
|
||||
|
||||
if (active === undefined || iteration === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isActive = active === true || active === "true"
|
||||
const iterationNum = typeof iteration === "number" ? iteration : Number(iteration)
|
||||
|
||||
if (isNaN(iterationNum)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const stripQuotes = (val: unknown): string => {
|
||||
const str = String(val ?? "")
|
||||
return str.replace(/^["']|["']$/g, "")
|
||||
}
|
||||
|
||||
return {
|
||||
active: isActive,
|
||||
iteration: iterationNum,
|
||||
max_iterations: Number(data.max_iterations) || DEFAULT_MAX_ITERATIONS,
|
||||
completion_promise: stripQuotes(data.completion_promise) || DEFAULT_COMPLETION_PROMISE,
|
||||
started_at: stripQuotes(data.started_at) || new Date().toISOString(),
|
||||
prompt: body.trim(),
|
||||
session_id: data.session_id ? stripQuotes(data.session_id) : undefined,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function writeState(
|
||||
directory: string,
|
||||
state: RalphLoopState,
|
||||
customPath?: string
|
||||
): boolean {
|
||||
const filePath = getStateFilePath(directory, customPath)
|
||||
|
||||
try {
|
||||
const dir = dirname(filePath)
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
}
|
||||
|
||||
const sessionIdLine = state.session_id ? `session_id: "${state.session_id}"\n` : ""
|
||||
const content = `---
|
||||
active: ${state.active}
|
||||
iteration: ${state.iteration}
|
||||
max_iterations: ${state.max_iterations}
|
||||
completion_promise: "${state.completion_promise}"
|
||||
started_at: "${state.started_at}"
|
||||
${sessionIdLine}---
|
||||
${state.prompt}
|
||||
`
|
||||
|
||||
writeFileSync(filePath, content, "utf-8")
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function clearState(directory: string, customPath?: string): boolean {
|
||||
const filePath = getStateFilePath(directory, customPath)
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function incrementIteration(
|
||||
directory: string,
|
||||
customPath?: string
|
||||
): RalphLoopState | null {
|
||||
const state = readState(directory, customPath)
|
||||
if (!state) return null
|
||||
|
||||
state.iteration += 1
|
||||
if (writeState(directory, state, customPath)) {
|
||||
return state
|
||||
}
|
||||
return null
|
||||
}
|
||||
15
src/hooks/ralph-loop/types.ts
Normal file
15
src/hooks/ralph-loop/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { RalphLoopConfig } from "../../config"
|
||||
|
||||
export interface RalphLoopState {
|
||||
active: boolean
|
||||
iteration: number
|
||||
max_iterations: number
|
||||
completion_promise: string
|
||||
started_at: string
|
||||
prompt: string
|
||||
session_id?: string
|
||||
}
|
||||
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir();
|
||||
export const RULES_INJECTOR_STORAGE = join(OPENCODE_STORAGE, "rules-injector");
|
||||
|
||||
export const PROJECT_MARKERS = [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
import { getOpenCodeStorageDir } from "../../shared/data-path"
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
export const OPENCODE_STORAGE = getOpenCodeStorageDir()
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
|
||||
@@ -135,7 +135,16 @@ export function findEmptyMessageByIndex(sessionID: string, targetIndex: number):
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
// API index may differ from storage index due to system messages
|
||||
const indicesToTry = [targetIndex, targetIndex - 1, targetIndex - 2]
|
||||
const indicesToTry = [
|
||||
targetIndex,
|
||||
targetIndex - 1,
|
||||
targetIndex + 1,
|
||||
targetIndex - 2,
|
||||
targetIndex + 2,
|
||||
targetIndex - 3,
|
||||
targetIndex - 4,
|
||||
targetIndex - 5,
|
||||
]
|
||||
|
||||
for (const idx of indicesToTry) {
|
||||
if (idx < 0 || idx >= messages.length) continue
|
||||
|
||||
359
src/hooks/think-mode/index.test.ts
Normal file
359
src/hooks/think-mode/index.test.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
import { describe, expect, it, beforeEach, mock } from "bun:test"
|
||||
import type { ThinkModeInput } from "./types"
|
||||
|
||||
const logMock = mock(() => {})
|
||||
|
||||
mock.module("../../shared", () => ({
|
||||
log: logMock,
|
||||
}))
|
||||
|
||||
const { createThinkModeHook, clearThinkModeState } = await import("./index")
|
||||
|
||||
/**
|
||||
* Helper to create a mock ThinkModeInput for testing
|
||||
*/
|
||||
function createMockInput(
|
||||
providerID: string,
|
||||
modelID: string,
|
||||
promptText: string
|
||||
): ThinkModeInput {
|
||||
return {
|
||||
parts: [{ type: "text", text: promptText }],
|
||||
message: {
|
||||
model: {
|
||||
providerID,
|
||||
modelID,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type helper for accessing dynamically injected properties on message
|
||||
*/
|
||||
type MessageWithInjectedProps = Record<string, unknown>
|
||||
|
||||
describe("createThinkModeHook integration", () => {
|
||||
const sessionID = "test-session-id"
|
||||
|
||||
beforeEach(() => {
|
||||
clearThinkModeState(sessionID)
|
||||
})
|
||||
|
||||
describe("GitHub Copilot provider integration", () => {
|
||||
describe("Claude models", () => {
|
||||
it("should activate thinking mode for github-copilot Claude with think keyword", async () => {
|
||||
// #given a github-copilot Claude model and prompt with "think" keyword
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4-5",
|
||||
"Please think deeply about this problem"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant and inject thinking config
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
|
||||
expect(message.thinking).toBeDefined()
|
||||
expect((message.thinking as Record<string, unknown>)?.type).toBe(
|
||||
"enabled"
|
||||
)
|
||||
expect(
|
||||
(message.thinking as Record<string, unknown>)?.budgetTokens
|
||||
).toBe(64000)
|
||||
})
|
||||
|
||||
it("should handle github-copilot Claude with dots in version", async () => {
|
||||
// #given a github-copilot Claude model with dot format (claude-opus-4.5)
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4.5",
|
||||
"ultrathink mode"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant (hyphen format)
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
|
||||
expect(message.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
it("should handle github-copilot Claude Sonnet", async () => {
|
||||
// #given a github-copilot Claude Sonnet model
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-sonnet-4-5",
|
||||
"think about this"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-sonnet-4-5-high")
|
||||
expect(message.thinking).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Gemini models", () => {
|
||||
it("should activate thinking mode for github-copilot Gemini Pro", async () => {
|
||||
// #given a github-copilot Gemini Pro model
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"gemini-3-pro-preview",
|
||||
"think about this"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant and inject google thinking config
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gemini-3-pro-preview-high")
|
||||
expect(message.providerOptions).toBeDefined()
|
||||
const googleOptions = (
|
||||
message.providerOptions as Record<string, unknown>
|
||||
)?.google as Record<string, unknown>
|
||||
expect(googleOptions?.thinkingConfig).toBeDefined()
|
||||
})
|
||||
|
||||
it("should activate thinking mode for github-copilot Gemini Flash", async () => {
|
||||
// #given a github-copilot Gemini Flash model
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"gemini-3-flash-preview",
|
||||
"ultrathink"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gemini-3-flash-preview-high")
|
||||
expect(message.providerOptions).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("GPT models", () => {
|
||||
it("should activate thinking mode for github-copilot GPT-5.2", async () => {
|
||||
// #given a github-copilot GPT-5.2 model
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"gpt-5.2",
|
||||
"please think"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant and inject openai thinking config
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gpt-5-2-high")
|
||||
expect(message.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should activate thinking mode for github-copilot GPT-5", async () => {
|
||||
// #given a github-copilot GPT-5 model
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput("github-copilot", "gpt-5", "think deeply")
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should upgrade to high variant
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gpt-5-high")
|
||||
expect(message.reasoning_effort).toBe("high")
|
||||
})
|
||||
})
|
||||
|
||||
describe("No think keyword", () => {
|
||||
it("should NOT activate for github-copilot without think keyword", async () => {
|
||||
// #given a prompt without any think keyword
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4-5",
|
||||
"Just do this task"
|
||||
)
|
||||
const originalModelID = input.message.model?.modelID
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should NOT change model or inject config
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe(originalModelID)
|
||||
expect(message.thinking).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Backwards compatibility with direct providers", () => {
|
||||
it("should still work for direct anthropic provider", async () => {
|
||||
// #given direct anthropic provider
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"anthropic",
|
||||
"claude-sonnet-4-5",
|
||||
"think about this"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should work as before
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-sonnet-4-5-high")
|
||||
expect(message.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
it("should still work for direct google provider", async () => {
|
||||
// #given direct google provider
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"google",
|
||||
"gemini-3-pro",
|
||||
"think about this"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should work as before
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
|
||||
expect(message.providerOptions).toBeDefined()
|
||||
})
|
||||
|
||||
it("should still work for direct openai provider", async () => {
|
||||
// #given direct openai provider
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput("openai", "gpt-5", "think about this")
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should work
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gpt-5-high")
|
||||
expect(message.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should still work for amazon-bedrock provider", async () => {
|
||||
// #given amazon-bedrock provider
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"amazon-bedrock",
|
||||
"claude-sonnet-4-5",
|
||||
"think"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should inject bedrock thinking config
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-sonnet-4-5-high")
|
||||
expect(message.reasoningConfig).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Already-high variants", () => {
|
||||
it("should NOT re-upgrade already-high variants", async () => {
|
||||
// #given an already-high variant model
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"claude-opus-4-5-high",
|
||||
"think deeply"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should NOT modify the model (already high)
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high")
|
||||
// No additional thinking config should be injected
|
||||
expect(message.thinking).toBeUndefined()
|
||||
})
|
||||
|
||||
it("should NOT re-upgrade already-high GPT variants", async () => {
|
||||
// #given an already-high GPT variant
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"gpt-5.2-high",
|
||||
"ultrathink"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should NOT modify the model
|
||||
const message = input.message as MessageWithInjectedProps
|
||||
expect(input.message.model?.modelID).toBe("gpt-5.2-high")
|
||||
expect(message.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Unknown models", () => {
|
||||
it("should not crash for unknown models via github-copilot", async () => {
|
||||
// #given an unknown model type
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput(
|
||||
"github-copilot",
|
||||
"llama-3-70b",
|
||||
"think about this"
|
||||
)
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should not crash and model should remain unchanged
|
||||
expect(input.message.model?.modelID).toBe("llama-3-70b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("should handle missing model gracefully", async () => {
|
||||
// #given input without a model
|
||||
const hook = createThinkModeHook()
|
||||
const input: ThinkModeInput = {
|
||||
parts: [{ type: "text", text: "think about this" }],
|
||||
message: {},
|
||||
}
|
||||
|
||||
// #when the chat.params hook is called
|
||||
// #then should not crash
|
||||
await expect(
|
||||
hook["chat.params"](input, sessionID)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it("should handle empty prompt gracefully", async () => {
|
||||
// #given empty prompt
|
||||
const hook = createThinkModeHook()
|
||||
const input = createMockInput("github-copilot", "claude-opus-4-5", "")
|
||||
|
||||
// #when the chat.params hook is called
|
||||
await hook["chat.params"](input, sessionID)
|
||||
|
||||
// #then should not upgrade (no think keyword)
|
||||
expect(input.message.model?.modelID).toBe("claude-opus-4-5")
|
||||
})
|
||||
})
|
||||
})
|
||||
325
src/hooks/think-mode/switcher.test.ts
Normal file
325
src/hooks/think-mode/switcher.test.ts
Normal file
@@ -0,0 +1,325 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import {
|
||||
getHighVariant,
|
||||
getThinkingConfig,
|
||||
isAlreadyHighVariant,
|
||||
THINKING_CONFIGS,
|
||||
} from "./switcher"
|
||||
|
||||
describe("think-mode switcher", () => {
|
||||
describe("GitHub Copilot provider support", () => {
|
||||
describe("Claude models via github-copilot", () => {
|
||||
it("should resolve github-copilot Claude Opus to anthropic config", () => {
|
||||
// #given a github-copilot provider with Claude Opus model
|
||||
const providerID = "github-copilot"
|
||||
const modelID = "claude-opus-4-5"
|
||||
|
||||
// #when getting thinking config
|
||||
const config = getThinkingConfig(providerID, modelID)
|
||||
|
||||
// #then should return anthropic thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
expect((config?.thinking as Record<string, unknown>)?.type).toBe(
|
||||
"enabled"
|
||||
)
|
||||
expect((config?.thinking as Record<string, unknown>)?.budgetTokens).toBe(
|
||||
64000
|
||||
)
|
||||
})
|
||||
|
||||
it("should resolve github-copilot Claude Sonnet to anthropic config", () => {
|
||||
// #given a github-copilot provider with Claude Sonnet model
|
||||
const config = getThinkingConfig("github-copilot", "claude-sonnet-4-5")
|
||||
|
||||
// #then should return anthropic thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
it("should handle Claude with dots in version number", () => {
|
||||
// #given a model ID with dots (claude-opus-4.5)
|
||||
const config = getThinkingConfig("github-copilot", "claude-opus-4.5")
|
||||
|
||||
// #then should still return anthropic thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Gemini models via github-copilot", () => {
|
||||
it("should resolve github-copilot Gemini Pro to google config", () => {
|
||||
// #given a github-copilot provider with Gemini Pro model
|
||||
const config = getThinkingConfig("github-copilot", "gemini-3-pro-preview")
|
||||
|
||||
// #then should return google thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.providerOptions).toBeDefined()
|
||||
const googleOptions = (
|
||||
config?.providerOptions as Record<string, unknown>
|
||||
)?.google as Record<string, unknown>
|
||||
expect(googleOptions?.thinkingConfig).toBeDefined()
|
||||
})
|
||||
|
||||
it("should resolve github-copilot Gemini Flash to google config", () => {
|
||||
// #given a github-copilot provider with Gemini Flash model
|
||||
const config = getThinkingConfig(
|
||||
"github-copilot",
|
||||
"gemini-3-flash-preview"
|
||||
)
|
||||
|
||||
// #then should return google thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.providerOptions).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("GPT models via github-copilot", () => {
|
||||
it("should resolve github-copilot GPT-5.2 to openai config", () => {
|
||||
// #given a github-copilot provider with GPT-5.2 model
|
||||
const config = getThinkingConfig("github-copilot", "gpt-5.2")
|
||||
|
||||
// #then should return openai thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should resolve github-copilot GPT-5 to openai config", () => {
|
||||
// #given a github-copilot provider with GPT-5 model
|
||||
const config = getThinkingConfig("github-copilot", "gpt-5")
|
||||
|
||||
// #then should return openai thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should resolve github-copilot o1 to openai config", () => {
|
||||
// #given a github-copilot provider with o1 model
|
||||
const config = getThinkingConfig("github-copilot", "o1-preview")
|
||||
|
||||
// #then should return openai thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should resolve github-copilot o3 to openai config", () => {
|
||||
// #given a github-copilot provider with o3 model
|
||||
const config = getThinkingConfig("github-copilot", "o3-mini")
|
||||
|
||||
// #then should return openai thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.reasoning_effort).toBe("high")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Unknown models via github-copilot", () => {
|
||||
it("should return null for unknown model types", () => {
|
||||
// #given a github-copilot provider with unknown model
|
||||
const config = getThinkingConfig("github-copilot", "llama-3-70b")
|
||||
|
||||
// #then should return null (no matching provider)
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model ID normalization", () => {
|
||||
describe("getHighVariant with dots vs hyphens", () => {
|
||||
it("should handle dots in Claude version numbers", () => {
|
||||
// #given a Claude model ID with dot format
|
||||
const variant = getHighVariant("claude-opus-4.5")
|
||||
|
||||
// #then should return high variant with hyphen format
|
||||
expect(variant).toBe("claude-opus-4-5-high")
|
||||
})
|
||||
|
||||
it("should handle hyphens in Claude version numbers", () => {
|
||||
// #given a Claude model ID with hyphen format
|
||||
const variant = getHighVariant("claude-opus-4-5")
|
||||
|
||||
// #then should return high variant
|
||||
expect(variant).toBe("claude-opus-4-5-high")
|
||||
})
|
||||
|
||||
it("should handle dots in GPT version numbers", () => {
|
||||
// #given a GPT model ID with dot format (gpt-5.2)
|
||||
const variant = getHighVariant("gpt-5.2")
|
||||
|
||||
// #then should return high variant
|
||||
expect(variant).toBe("gpt-5-2-high")
|
||||
})
|
||||
|
||||
it("should handle dots in GPT-5.1 codex variants", () => {
|
||||
// #given a GPT-5.1-codex model ID
|
||||
const variant = getHighVariant("gpt-5.1-codex")
|
||||
|
||||
// #then should return high variant
|
||||
expect(variant).toBe("gpt-5-1-codex-high")
|
||||
})
|
||||
|
||||
it("should handle Gemini preview variants", () => {
|
||||
// #given Gemini preview model IDs
|
||||
expect(getHighVariant("gemini-3-pro-preview")).toBe(
|
||||
"gemini-3-pro-preview-high"
|
||||
)
|
||||
expect(getHighVariant("gemini-3-flash-preview")).toBe(
|
||||
"gemini-3-flash-preview-high"
|
||||
)
|
||||
})
|
||||
|
||||
it("should return null for already-high variants", () => {
|
||||
// #given model IDs that are already high variants
|
||||
expect(getHighVariant("claude-opus-4-5-high")).toBeNull()
|
||||
expect(getHighVariant("gpt-5-2-high")).toBeNull()
|
||||
expect(getHighVariant("gemini-3-pro-high")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for unknown models", () => {
|
||||
// #given unknown model IDs
|
||||
expect(getHighVariant("llama-3-70b")).toBeNull()
|
||||
expect(getHighVariant("mistral-large")).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAlreadyHighVariant", () => {
|
||||
it("should detect -high suffix", () => {
|
||||
// #given model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("claude-opus-4-5-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should detect -high suffix after normalization", () => {
|
||||
// #given model IDs with dots that end in -high
|
||||
expect(isAlreadyHighVariant("gpt-5.2-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for base models", () => {
|
||||
// #given base model IDs without -high suffix
|
||||
expect(isAlreadyHighVariant("claude-opus-4-5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("claude-opus-4.5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
|
||||
expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false)
|
||||
})
|
||||
|
||||
it("should return false for models with 'high' in name but not suffix", () => {
|
||||
// #given model IDs that contain 'high' but not as suffix
|
||||
expect(isAlreadyHighVariant("high-performance-model")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getThinkingConfig", () => {
|
||||
describe("Already high variants", () => {
|
||||
it("should return null for already-high variants", () => {
|
||||
// #given already-high model variants
|
||||
expect(
|
||||
getThinkingConfig("anthropic", "claude-opus-4-5-high")
|
||||
).toBeNull()
|
||||
expect(getThinkingConfig("openai", "gpt-5-2-high")).toBeNull()
|
||||
expect(getThinkingConfig("google", "gemini-3-pro-high")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for already-high variants via github-copilot", () => {
|
||||
// #given already-high model variants via github-copilot
|
||||
expect(
|
||||
getThinkingConfig("github-copilot", "claude-opus-4-5-high")
|
||||
).toBeNull()
|
||||
expect(getThinkingConfig("github-copilot", "gpt-5.2-high")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Non-thinking-capable models", () => {
|
||||
it("should return null for non-thinking-capable models", () => {
|
||||
// #given models that don't support thinking mode
|
||||
expect(getThinkingConfig("anthropic", "claude-2")).toBeNull()
|
||||
expect(getThinkingConfig("openai", "gpt-4")).toBeNull()
|
||||
expect(getThinkingConfig("google", "gemini-1")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Unknown providers", () => {
|
||||
it("should return null for unknown providers", () => {
|
||||
// #given unknown provider IDs
|
||||
expect(getThinkingConfig("unknown-provider", "some-model")).toBeNull()
|
||||
expect(getThinkingConfig("azure", "gpt-5")).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Direct provider configs (backwards compatibility)", () => {
|
||||
it("should still work for direct anthropic provider", () => {
|
||||
// #given direct anthropic provider
|
||||
const config = getThinkingConfig("anthropic", "claude-opus-4-5")
|
||||
|
||||
// #then should return anthropic thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
expect((config?.thinking as Record<string, unknown>)?.type).toBe("enabled")
|
||||
})
|
||||
|
||||
it("should still work for direct google provider", () => {
|
||||
// #given direct google provider
|
||||
const config = getThinkingConfig("google", "gemini-3-pro")
|
||||
|
||||
// #then should return google thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.providerOptions).toBeDefined()
|
||||
})
|
||||
|
||||
it("should still work for amazon-bedrock provider", () => {
|
||||
// #given amazon-bedrock provider with claude model
|
||||
const config = getThinkingConfig("amazon-bedrock", "claude-sonnet-4-5")
|
||||
|
||||
// #then should return bedrock thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.reasoningConfig).toBeDefined()
|
||||
})
|
||||
|
||||
it("should still work for google-vertex provider", () => {
|
||||
// #given google-vertex provider
|
||||
const config = getThinkingConfig("google-vertex", "gemini-3-pro")
|
||||
|
||||
// #then should return google-vertex thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.providerOptions).toBeDefined()
|
||||
const vertexOptions = (config?.providerOptions as Record<string, unknown>)?.[
|
||||
"google-vertex"
|
||||
] as Record<string, unknown>
|
||||
expect(vertexOptions?.thinkingConfig).toBeDefined()
|
||||
})
|
||||
|
||||
it("should work for direct openai provider", () => {
|
||||
// #given direct openai provider
|
||||
const config = getThinkingConfig("openai", "gpt-5")
|
||||
|
||||
// #then should return openai thinking config
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.reasoning_effort).toBe("high")
|
||||
})
|
||||
})
|
||||
|
||||
describe("THINKING_CONFIGS structure", () => {
|
||||
it("should have correct structure for anthropic", () => {
|
||||
const config = THINKING_CONFIGS.anthropic
|
||||
expect(config.thinking).toBeDefined()
|
||||
expect(config.maxTokens).toBe(128000)
|
||||
})
|
||||
|
||||
it("should have correct structure for google", () => {
|
||||
const config = THINKING_CONFIGS.google
|
||||
expect(config.providerOptions).toBeDefined()
|
||||
})
|
||||
|
||||
it("should have correct structure for openai", () => {
|
||||
const config = THINKING_CONFIGS.openai
|
||||
expect(config.reasoning_effort).toBe("high")
|
||||
})
|
||||
|
||||
it("should have correct structure for amazon-bedrock", () => {
|
||||
const config = THINKING_CONFIGS["amazon-bedrock"]
|
||||
expect(config.reasoningConfig).toBeDefined()
|
||||
expect(config.maxTokens).toBe(64000)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,67 @@
|
||||
/**
|
||||
* Think Mode Switcher
|
||||
*
|
||||
* This module handles "thinking mode" activation for reasoning-capable models.
|
||||
* When a user includes "think" keywords in their prompt, models are upgraded to
|
||||
* their high-reasoning variants with extended thinking budgets.
|
||||
*
|
||||
* PROVIDER ALIASING:
|
||||
* GitHub Copilot acts as a proxy provider that routes to underlying providers
|
||||
* (Anthropic, Google, OpenAI). We resolve the proxy to the actual provider
|
||||
* based on model name patterns, allowing GitHub Copilot to inherit thinking
|
||||
* configurations without duplication.
|
||||
*
|
||||
* NORMALIZATION:
|
||||
* Model IDs are normalized (dots → hyphens in version numbers) to handle API
|
||||
* inconsistencies defensively while maintaining backwards compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalizes model IDs to use consistent hyphen formatting.
|
||||
* GitHub Copilot may use dots (claude-opus-4.5) but our maps use hyphens (claude-opus-4-5).
|
||||
* This ensures lookups work regardless of format.
|
||||
*
|
||||
* @example
|
||||
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5"
|
||||
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
|
||||
* normalizeModelID("gpt-5.2") // "gpt-5-2"
|
||||
*/
|
||||
function normalizeModelID(modelID: string): string {
|
||||
// Replace dots with hyphens when followed by a digit
|
||||
// This handles version numbers like 4.5 → 4-5, 5.2 → 5-2
|
||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves proxy providers (like github-copilot) to their underlying provider.
|
||||
* This allows GitHub Copilot to inherit thinking configurations from the actual
|
||||
* model provider (Anthropic, Google, OpenAI).
|
||||
*
|
||||
* @example
|
||||
* resolveProvider("github-copilot", "claude-opus-4-5") // "anthropic"
|
||||
* resolveProvider("github-copilot", "gemini-3-pro") // "google"
|
||||
* resolveProvider("github-copilot", "gpt-5.2") // "openai"
|
||||
* resolveProvider("anthropic", "claude-opus-4-5") // "anthropic" (unchanged)
|
||||
*/
|
||||
function resolveProvider(providerID: string, modelID: string): string {
|
||||
// GitHub Copilot is a proxy - infer actual provider from model name
|
||||
if (providerID === "github-copilot") {
|
||||
const modelLower = modelID.toLowerCase()
|
||||
if (modelLower.includes("claude")) return "anthropic"
|
||||
if (modelLower.includes("gemini")) return "google"
|
||||
if (
|
||||
modelLower.includes("gpt") ||
|
||||
modelLower.includes("o1") ||
|
||||
modelLower.includes("o3")
|
||||
) {
|
||||
return "openai"
|
||||
}
|
||||
}
|
||||
|
||||
// Direct providers or unknown - return as-is
|
||||
return providerID
|
||||
}
|
||||
|
||||
// Maps model IDs to their "high reasoning" variant (internal convention)
|
||||
// For OpenAI models, this signals that reasoning_effort should be set to "high"
|
||||
const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
@@ -7,6 +71,9 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
// Gemini
|
||||
"gemini-3-pro": "gemini-3-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
||||
"gemini-3-pro-preview": "gemini-3-pro-preview-high",
|
||||
"gemini-3-flash": "gemini-3-flash-high",
|
||||
"gemini-3-flash-preview": "gemini-3-flash-preview-high",
|
||||
// GPT-5
|
||||
"gpt-5": "gpt-5-high",
|
||||
"gpt-5-mini": "gpt-5-mini-high",
|
||||
@@ -14,42 +81,20 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
"gpt-5-pro": "gpt-5-pro-high",
|
||||
"gpt-5-chat-latest": "gpt-5-chat-latest-high",
|
||||
// GPT-5.1
|
||||
"gpt-5.1": "gpt-5.1-high",
|
||||
"gpt-5.1-chat-latest": "gpt-5.1-chat-latest-high",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex-high",
|
||||
"gpt-5.1-codex-mini": "gpt-5.1-codex-mini-high",
|
||||
"gpt-5.1-codex-max": "gpt-5.1-codex-max-high",
|
||||
"gpt-5-1": "gpt-5-1-high",
|
||||
"gpt-5-1-chat-latest": "gpt-5-1-chat-latest-high",
|
||||
"gpt-5-1-codex": "gpt-5-1-codex-high",
|
||||
"gpt-5-1-codex-mini": "gpt-5-1-codex-mini-high",
|
||||
"gpt-5-1-codex-max": "gpt-5-1-codex-max-high",
|
||||
// GPT-5.2
|
||||
"gpt-5.2": "gpt-5.2-high",
|
||||
"gpt-5.2-chat-latest": "gpt-5.2-chat-latest-high",
|
||||
"gpt-5.2-pro": "gpt-5.2-pro-high",
|
||||
"gpt-5-2": "gpt-5-2-high",
|
||||
"gpt-5-2-chat-latest": "gpt-5-2-chat-latest-high",
|
||||
"gpt-5-2-pro": "gpt-5-2-pro-high",
|
||||
}
|
||||
|
||||
const ALREADY_HIGH: Set<string> = new Set([
|
||||
// Claude
|
||||
"claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5-high",
|
||||
// Gemini
|
||||
"gemini-3-pro-high",
|
||||
// GPT-5
|
||||
"gpt-5-high",
|
||||
"gpt-5-mini-high",
|
||||
"gpt-5-nano-high",
|
||||
"gpt-5-pro-high",
|
||||
"gpt-5-chat-latest-high",
|
||||
// GPT-5.1
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-chat-latest-high",
|
||||
"gpt-5.1-codex-high",
|
||||
"gpt-5.1-codex-mini-high",
|
||||
"gpt-5.1-codex-max-high",
|
||||
// GPT-5.2
|
||||
"gpt-5.2-high",
|
||||
"gpt-5.2-chat-latest-high",
|
||||
"gpt-5.2-pro-high",
|
||||
])
|
||||
const ALREADY_HIGH: Set<string> = new Set(Object.values(HIGH_VARIANT_MAP))
|
||||
|
||||
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
|
||||
export const THINKING_CONFIGS = {
|
||||
anthropic: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
@@ -82,42 +127,59 @@ export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
openai: {
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
} as const satisfies Record<string, Record<string, unknown>>
|
||||
|
||||
const THINKING_CAPABLE_MODELS: Record<string, string[]> = {
|
||||
const THINKING_CAPABLE_MODELS = {
|
||||
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
|
||||
"amazon-bedrock": ["claude", "anthropic"],
|
||||
google: ["gemini-2", "gemini-3"],
|
||||
"google-vertex": ["gemini-2", "gemini-3"],
|
||||
}
|
||||
openai: ["gpt-5", "o1", "o3"],
|
||||
} as const satisfies Record<string, readonly string[]>
|
||||
|
||||
export function getHighVariant(modelID: string): string | null {
|
||||
if (ALREADY_HIGH.has(modelID)) {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
|
||||
if (ALREADY_HIGH.has(normalized)) {
|
||||
return null
|
||||
}
|
||||
return HIGH_VARIANT_MAP[modelID] ?? null
|
||||
return HIGH_VARIANT_MAP[normalized] ?? null
|
||||
}
|
||||
|
||||
export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
return ALREADY_HIGH.has(modelID) || modelID.endsWith("-high")
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return ALREADY_HIGH.has(normalized) || normalized.endsWith("-high")
|
||||
}
|
||||
|
||||
type ThinkingProvider = keyof typeof THINKING_CONFIGS
|
||||
|
||||
function isThinkingProvider(provider: string): provider is ThinkingProvider {
|
||||
return provider in THINKING_CONFIGS
|
||||
}
|
||||
|
||||
export function getThinkingConfig(
|
||||
providerID: string,
|
||||
modelID: string
|
||||
): Record<string, unknown> | null {
|
||||
if (isAlreadyHighVariant(modelID)) {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
|
||||
if (isAlreadyHighVariant(normalized)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config = THINKING_CONFIGS[providerID]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[providerID]
|
||||
const resolvedProvider = resolveProvider(providerID, modelID)
|
||||
|
||||
if (!config || !capablePatterns) {
|
||||
if (!isThinkingProvider(resolvedProvider)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelLower = modelID.toLowerCase()
|
||||
const config = THINKING_CONFIGS[resolvedProvider]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
|
||||
|
||||
const modelLower = normalized.toLowerCase()
|
||||
const isCapable = capablePatterns.some((pattern) =>
|
||||
modelLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
@@ -51,14 +51,15 @@ function isExtendedThinkingModel(modelID: string): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has tool parts (tool_use)
|
||||
* Check if a message has any content parts (tool_use, text, or other non-thinking content)
|
||||
*/
|
||||
function hasToolParts(parts: Part[]): boolean {
|
||||
function hasContentParts(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
return parts.some((part: Part) => {
|
||||
const type = part.type as string
|
||||
return type === "tool" || type === "tool_use"
|
||||
// Include tool parts and text parts (anything that's not thinking/reasoning)
|
||||
return type === "tool" || type === "tool_use" || type === "text"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -154,8 +155,8 @@ export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
// Only check assistant messages
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Check if message has tool parts but doesn't start with thinking
|
||||
if (hasToolParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Check if message has content parts but doesn't start with thinking
|
||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Find thinking content from previous turns
|
||||
const previousThinking = findPreviousThinkingContent(messages, i)
|
||||
|
||||
|
||||
404
src/hooks/todo-continuation-enforcer.test.ts
Normal file
404
src/hooks/todo-continuation-enforcer.test.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
]}),
|
||||
prompt: async (opts: any) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push({
|
||||
title: opts.body.title,
|
||||
message: opts.body.message,
|
||||
})
|
||||
return {}
|
||||
},
|
||||
},
|
||||
},
|
||||
directory: "/tmp/test",
|
||||
} as any
|
||||
}
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
return {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
setMainSession(undefined)
|
||||
subagentSessions.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainSession(undefined)
|
||||
subagentSessions.clear()
|
||||
})
|
||||
|
||||
test("should inject continuation when idle with incomplete todos", async () => {
|
||||
// #given - main session with incomplete todos
|
||||
const sessionID = "main-123"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
backgroundManager: createMockBackgroundManager(false),
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #then - countdown toast shown
|
||||
await new Promise(r => setTimeout(r, 100))
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(toastCalls[0].title).toBe("Todo Continuation")
|
||||
|
||||
// #then - after countdown, continuation injected
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
|
||||
})
|
||||
|
||||
test("should not inject when all todos are complete", async () => {
|
||||
// #given - session with all todos complete
|
||||
const sessionID = "main-456"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const mockInput = createMockPluginInput()
|
||||
mockInput.client.session.todo = async () => ({ data: [
|
||||
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
|
||||
]})
|
||||
|
||||
const hook = createTodoContinuationEnforcer(mockInput, {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject when background tasks are running", async () => {
|
||||
// #given - session with running background tasks
|
||||
const sessionID = "main-789"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
|
||||
backgroundManager: createMockBackgroundManager(true),
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should not inject for non-main session", async () => {
|
||||
// #given - main session set, different session goes idle
|
||||
setMainSession("main-session")
|
||||
const otherSession = "other-session"
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - non-main session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID: otherSession } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject for background task session (subagent)", async () => {
|
||||
// #given - main session set, background task session registered
|
||||
setMainSession("main-session")
|
||||
const bgTaskSession = "bg-task-session"
|
||||
subagentSessions.add(bgTaskSession)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - background task session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID: bgTaskSession } },
|
||||
})
|
||||
|
||||
// #then - continuation injected for background task session
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
|
||||
test("should skip injection after recent error", async () => {
|
||||
// #given - session that just had an error
|
||||
const sessionID = "main-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (error cooldown)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should clear error state on user message and allow injection", async () => {
|
||||
// #given - session with error, then user clears it
|
||||
const sessionID = "main-error-clear"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message (clears error immediately)
|
||||
await hook.handler({
|
||||
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (error was cleared by user message)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on user message", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-cancel"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - wait past countdown time and verify no injection
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-assistant"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should cancel countdown on tool execution", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-tool"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - tool starts executing
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.before", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (cancelled)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should skip injection during recovery mode", async () => {
|
||||
// #given - session in recovery mode
|
||||
const sessionID = "main-recovery"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - mark as recovering
|
||||
hook.markRecovering(sessionID)
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject after recovery complete", async () => {
|
||||
// #given - session was in recovery, now complete
|
||||
const sessionID = "main-recovery-done"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - mark as recovering then complete
|
||||
hook.markRecovering(sessionID)
|
||||
hook.markRecoveryComplete(sessionID)
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cleanup on session deleted", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-delete"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - session is deleted during countdown
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (cleaned up)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should show countdown toast updates", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-toast"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
|
||||
expect(toastCalls[0].message).toContain("2s")
|
||||
})
|
||||
|
||||
test("should not have 10s throttle between injections", async () => {
|
||||
// #given - new hook instance (no prior state)
|
||||
const sessionID = "main-no-throttle"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - first idle cycle completes
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
|
||||
// #when - immediately trigger second idle (no 10s wait needed)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
}, { timeout: 10000 })
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user