Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
3788da2046 release: v2.7.3 2025-12-29 07:27:25 +00:00
91 changed files with 331 additions and 6055 deletions

View File

@@ -316,9 +316,10 @@ jobs:
---
Write everything using the todo tools.
First, acknowledge with `gh issue comment NUMBER_PLACEHOLDER --body "👋 Hey @AUTHOR_PLACEHOLDER! I'm on it..."`
Then 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
)

0
= Normal file
View File

View File

@@ -635,12 +635,6 @@ 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 エージェントによる検索最大化
@@ -874,7 +868,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-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`
利用可能なフック:`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`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
@@ -926,22 +920,20 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| オプション | デフォルト | 説明 |
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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動的コンテキスト整理を有効化 - トークン制限超過時に最初に実行されます。コンパクション前に重複したツール呼び出しと古いツール出力を整理します。 |
| オプション | デフォルト | 説明 |
| --------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
| `truncate_all_tool_outputs` | `true` | プロンプトが長くなりすぎるのを防ぐため、コンテキストウィンドウの使用状況に基づいてすべてのツール出力を動的に切り詰めます。完全なツール出力が必要な場合は`false`に設定して無効化ます。 |
| `dcp_for_compaction` | `false` | 有効にすると、トークン制限エラー発生時にDCPDynamic Context Pruningが最初に実行され、その後コンパクションが実行されます。DCPが不要なコンテキストを整理した後、すぐにコンパクションが進行します。トークン制限に達した際によりスマートな回復が必要な場合は有効にしてください。 |
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。

View File

@@ -628,12 +628,6 @@ 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 에이전트로 검색 극대화
@@ -871,7 +865,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-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`
사용 가능한 훅: `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`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
@@ -923,22 +917,20 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| 옵션 | 기본값 | 설명 |
| --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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(동적 컨텍스트 정리) 활성화 - 토큰 제한 초과 시 먼저 실행됩니다. 컴팩션 전에 중복 도구 호출과 오래된 도구 출력을 정리합니다. |
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |
| `truncate_all_tool_outputs` | `true` | 프롬프트가 너무 길어지는 것을 방지하기 위해 컨텍스트 윈도우 사용량에 따라 모든 도구 출력을 동적으로 잘라냅니다. 전체 도구 출력이 필요한 경우 `false`로 설정하여 비활성화하세요. |
| `dcp_for_compaction` | `false` | 활성화하면, 토큰 제한 에러 발생 시 DCP(Dynamic Context Pruning)가 가장 먼저 실행되고, 그 다음 compaction이 실행됩니다. DCP가 불필요한 컨텍스트를 정리한 후 바로 compaction이 진행됩니다. 토큰 제한에 도달했을 때 더 스마트한 복구를 원하면 활성화하세요. |
**경고**: 이 기능들은 실험적이며 예상치 못한 동작을 유발할 수 있습니다. 의미를 이해한 경우에만 활성화하세요.

View File

@@ -667,12 +667,6 @@ 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
@@ -910,7 +904,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-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`
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`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
**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`.
@@ -962,22 +956,20 @@ 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
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": 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. |
| `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. |
| `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_for_compaction` | `false` | When enabled, Dynamic Context Pruning (DCP) runs FIRST when token limit errors occur, before attempting compaction. DCP prunes redundant context, then compaction runs immediately. Enable this for smarter recovery when hitting token limits. |
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.

View File

@@ -639,12 +639,6 @@ 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 并行搜索,掘地三尺
@@ -878,7 +872,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-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`
可关的 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`、`preemptive-compaction`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
@@ -930,22 +924,20 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
```json
{
"experimental": {
"preemptive_compaction": true,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"truncate_all_tool_outputs": false,
"dcp_on_compaction_failure": true
}
}
```
| 选项 | 默认值 | 说明 |
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `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 限制时首先执行。在压缩前清理重复的工具调用和旧的工具输出。 |
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
| `truncate_all_tool_outputs` | `true` | 为防止提示过长,根据上下文窗口使用情况动态截断所有工具输出。如需完整工具输出,设置为 `false` 禁用此功能。 |
| `dcp_for_compaction` | `false` | 启用后,当发生 token 限制错误时DCP动态上下文剪枝首先运行然后立即执行压缩。DCP 清理不必要的上下文后,压缩立即进行。当达到 token 限制时需要更智能的恢复请启用此选项。 |
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。

View File

@@ -50,7 +50,7 @@
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"anthropic-context-window-limit-recovery",
"anthropic-auto-compact",
"rules-injector",
"background-notification",
"auto-update-checker",
@@ -60,8 +60,7 @@
"non-interactive-env",
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
"ralph-loop"
"thinking-block-validator"
]
}
},
@@ -1411,6 +1410,7 @@
"maximum": 0.95
},
"truncate_all_tool_outputs": {
"default": true,
"type": "boolean"
},
"dynamic_context_pruning": {
@@ -1511,143 +1511,6 @@
},
"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"
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.8.3",
"version": "2.7.3",
"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 --external @ast-grep/napi && 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 && bun run build:schema",
"build:schema": "bun run script/build-schema.ts",
"clean": "rm -rf dist",
"prepublishOnly": "bun run clean && bun run build",

View File

@@ -79,30 +79,6 @@
"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
}
]
}

View File

@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../shared"
@@ -14,49 +14,6 @@ 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`)
@@ -85,46 +42,12 @@ export function detectConfigFormat(): { format: ConfigFormat; path: string } {
return { format: "none", path: OPENCODE_JSON }
}
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 {
function parseConfig(path: string, isJsonc: boolean): OpenCodeConfig | null {
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")
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}`) }
return parseJsonc<OpenCodeConfig>(content)
} catch {
return null
}
}
@@ -135,11 +58,7 @@ function ensureConfigDir(): void {
}
export function addPluginToOpenCodeConfig(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
ensureConfigDir()
const { format, path } = detectConfigFormat()
const pluginName = "oh-my-opencode"
@@ -151,12 +70,11 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
return { success: true, configPath: path }
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
return { success: false, configPath: path, error: parseResult.error ?? "Failed to parse config file" }
const config = parseConfig(path, format === "jsonc")
if (!config) {
return { success: false, configPath: path, error: "Failed to parse config" }
}
const config = parseResult.config
const plugins = config.plugin ?? []
if (plugins.some((p) => p.startsWith(pluginName))) {
return { success: true, configPath: path }
@@ -186,7 +104,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
return { success: true, configPath: path }
} catch (err) {
return { success: false, configPath: path, error: formatErrorWithSuggestion(err, "update opencode config") }
return { success: false, configPath: path, error: String(err) }
}
}
@@ -267,48 +185,24 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
}
export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
ensureConfigDir()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(OMO_CONFIG)) {
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
}
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")
} 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: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
return { success: false, configPath: OMO_CONFIG, error: String(err) }
}
}
@@ -347,25 +241,11 @@ export async function getOpenCodeVersion(): Promise<string | null> {
}
export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMergeResult> {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
ensureConfigDir()
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null
const plugins: string[] = existingConfig?.plugin ?? []
if (config.hasGemini) {
@@ -386,37 +266,18 @@ 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: formatErrorWithSuggestion(err, "add auth plugins to config") }
return { success: false, configPath: path, error: String(err) }
}
}
export function setupChatGPTHotfix(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
ensureConfigDir()
try {
let packageJson: Record<string, unknown> = {}
if (existsSync(OPENCODE_PACKAGE_JSON)) {
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 content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
packageJson = JSON.parse(content)
}
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
@@ -426,65 +287,21 @@ 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: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: String(err) }
}
}
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",
})
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`,
}
await proc.exited
return proc.exitCode === 0
} catch {
return false
}
}
@@ -545,25 +362,11 @@ const CODEX_PROVIDER_CONFIG = {
}
export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
ensureConfigDir()
const { format, path } = detectConfigFormat()
try {
let existingConfig: OpenCodeConfig | null = null
if (format !== "none") {
const parseResult = parseConfigWithError(path)
if (parseResult.error && !parseResult.config) {
existingConfig = {}
} else {
existingConfig = parseResult.config
}
}
const existingConfig = format !== "none" ? parseConfig(path, format === "jsonc") : null
const newConfig = { ...(existingConfig ?? {}) }
const providers = (newConfig.provider ?? {}) as Record<string, unknown>
@@ -583,7 +386,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: formatErrorWithSuggestion(err, "add provider config") }
return { success: false, configPath: path, error: String(err) }
}
}
@@ -606,12 +409,11 @@ export function detectCurrentConfig(): DetectedConfig {
return result
}
const parseResult = parseConfigWithError(path)
if (!parseResult.config) {
const openCodeConfig = parseConfig(path, format === "jsonc")
if (!openCodeConfig) {
return result
}
const openCodeConfig = parseResult.config
const plugins = openCodeConfig.plugin ?? []
result.isInstalled = plugins.some((p) => p.startsWith("oh-my-opencode"))
@@ -627,20 +429,8 @@ 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 ?? {}
@@ -662,7 +452,7 @@ export function detectCurrentConfig(): DetectedConfig {
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
}
} catch {
/* intentionally empty - malformed omo config returns defaults from opencode config detection */
/* intentionally empty - malformed config returns defaults */
}
return result

View File

@@ -1,114 +0,0 @@
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)
})
})
})

View File

@@ -1,115 +0,0 @@
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,
},
]
}

View File

@@ -1,103 +0,0 @@
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)
})
})
})

View File

@@ -1,123 +0,0 @@
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,
}
}

View File

@@ -1,152 +0,0 @@
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)
})
})
})

View File

@@ -1,163 +0,0 @@
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,
},
]
}

View File

@@ -1,31 +0,0 @@
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(),
]
}

View File

@@ -1,117 +0,0 @@
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)
})
})
})

View File

@@ -1,85 +0,0 @@
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,
}
}

View File

@@ -1,117 +0,0 @@
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")
})
})
})

View File

@@ -1,128 +0,0 @@
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,
},
]
}

View File

@@ -1,139 +0,0 @@
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")
})
})
})

View File

@@ -1,118 +0,0 @@
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,
}
}

View File

@@ -1,109 +0,0 @@
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)
})
})
})

View File

@@ -1,127 +0,0 @@
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,
}
}

View File

@@ -1,148 +0,0 @@
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)
})
})
})

View File

@@ -1,177 +0,0 @@
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,
}
}

View File

@@ -1,70 +0,0 @@
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

View File

@@ -1,218 +0,0 @@
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)
})
})
})

View File

@@ -1,140 +0,0 @@
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
}

View File

@@ -1,11 +0,0 @@
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"

View File

@@ -1,153 +0,0 @@
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")
})
})
})

View File

@@ -1,132 +0,0 @@
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
}

View File

@@ -1,113 +0,0 @@
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
}

View File

@@ -3,11 +3,9 @@ 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
@@ -103,37 +101,6 @@ 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")

View File

@@ -79,11 +79,15 @@ 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 === "tool-invocation") {
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") {
const toolPart = part as { toolName?: string; state?: string }
console.error(
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)

View File

@@ -8,7 +8,6 @@ export {
BuiltinCommandNameSchema,
SisyphusAgentConfigSchema,
ExperimentalConfigSchema,
RalphLoopConfigSchema,
} from "./schema"
export type {
@@ -22,5 +21,4 @@ export type {
SisyphusAgentConfig,
ExperimentalConfig,
DynamicContextPruningConfig,
RalphLoopConfig,
} from "./schema"

View File

@@ -54,7 +54,7 @@ export const HookNameSchema = z.enum([
"directory-readme-injector",
"empty-task-response-detector",
"think-mode",
"anthropic-context-window-limit-recovery",
"anthropic-auto-compact",
"rules-injector",
"background-notification",
"auto-update-checker",
@@ -65,7 +65,6 @@ export const HookNameSchema = z.enum([
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
"ralph-loop",
])
export const BuiltinCommandNameSchema = z.enum([
@@ -164,65 +163,18 @@ export const DynamicContextPruningConfigSchema = z.object({
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
auto_resume: z.boolean().optional(),
/** Enable preemptive compaction at threshold (default: false) */
/** Enable preemptive compaction at threshold (default: true) */
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: false). Tool output truncator is enabled by default - disable via disabled_hooks. */
truncate_all_tool_outputs: z.boolean().optional(),
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
truncate_all_tool_outputs: z.boolean().default(true),
/** Dynamic context pruning configuration */
dynamic_context_pruning: DynamicContextPruningConfigSchema.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({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
@@ -236,8 +188,6 @@ export const OhMyOpenCodeConfigSchema = z.object({
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>
@@ -250,8 +200,5 @@ 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"

View File

@@ -19,7 +19,7 @@ features/
│ └── env-expander.ts # ${VAR} expansion
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
├── claude-code-session-state/ # Session state persistence
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
├── claude-code-skill-loader/ # Load skills from ~/.claude/skills/*/SKILL.md
└── hook-message-injector/ # Inject messages into conversation
```
@@ -30,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 | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
| Skills | `.claude/skills/` > `~/.claude/skills/` |
| Agents | `.claude/agents/` > `~/.claude/agents/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |

View File

@@ -1,7 +1,6 @@
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": {
@@ -15,23 +14,6 @@ $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(

View File

@@ -1,38 +0,0 @@
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.`

View File

@@ -1,6 +1,6 @@
import type { CommandDefinition } from "../claude-code-command-loader"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
export type BuiltinCommandName = "init-deep"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]

View File

@@ -1,2 +0,0 @@
export * from "./types"
export { createBuiltinSkills } from "./skills"

View File

@@ -1,5 +0,0 @@
import type { BuiltinSkill } from "./types"
export function createBuiltinSkills(): BuiltinSkill[] {
return []
}

View File

@@ -1,13 +0,0 @@
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
}

View File

@@ -9,7 +9,7 @@ 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 "../opencode-skill-loader/types"
import type { SkillMetadata } from "../claude-code-skill-loader/types"
import type { AgentFrontmatter } from "../claude-code-agent-loader/types"
import type { ClaudeCodeMcpConfig, McpServerConfig } from "../claude-code-mcp-loader/types"
import type {

View File

@@ -1,3 +1,2 @@
export * from "./types"
export * from "./loader"
export * from "./merger"

View File

@@ -0,0 +1,86 @@
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>)
}

View File

@@ -0,0 +1,16 @@
import type { CommandDefinition } from "../claude-code-command-loader/types"
export type SkillScope = "user" | "project"
export interface SkillMetadata {
name: string
description: string
model?: string
}
export interface LoadedSkillAsCommand {
name: string
path: string
definition: CommandDefinition
scope: SkillScope
}

View File

@@ -1,246 +0,0 @@
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")
}

View File

@@ -1,266 +0,0 @@
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())
}

View File

@@ -1,28 +0,0 @@
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[]
}

View File

@@ -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-context-window-limit-recovery/ # Auto-compact Claude at token limit
├── anthropic-auto-compact/ # 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
@@ -40,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-context-window-limit-recovery, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
| Session Management | session-recovery, anthropic-auto-compact, 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 |

View File

@@ -151,7 +151,6 @@ describe("executeCompact lock management", () => {
truncate_all_tool_outputs: false,
aggressive_truncation: true,
}
const dcpForCompaction = true
// #when: Execute compaction with experimental flag
await executeCompact(
@@ -161,7 +160,6 @@ describe("executeCompact lock management", () => {
mockClient,
directory,
experimental,
dcpForCompaction,
)
// #then: Lock should be cleared even on early return

View File

@@ -337,7 +337,6 @@ export async function executeCompact(
client: any,
directory: string,
experimental?: ExperimentalConfig,
dcpForCompaction?: boolean,
): Promise<void> {
if (autoCompactState.compactionInProgress.has(sessionID)) {
await (client as Client).tui
@@ -359,10 +358,10 @@ 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)
// DCP FIRST - run before any other recovery attempts when token limit exceeded
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
if (
dcpForCompaction !== false &&
experimental?.dcp_for_compaction &&
!dcpState.attempted &&
errorData?.currentTokens &&
errorData?.maxTokens &&
@@ -375,7 +374,7 @@ export async function executeCompact(
maxTokens: errorData.maxTokens,
});
const dcpConfig = experimental?.dynamic_context_pruning ?? {
const dcpConfig = experimental.dynamic_context_pruning ?? {
enabled: true,
notification: "detailed" as const,
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
@@ -619,7 +618,6 @@ export async function executeCompact(
client,
directory,
experimental,
dcpForCompaction,
);
}, 500);
return;
@@ -698,7 +696,6 @@ export async function executeCompact(
client,
directory,
experimental,
dcpForCompaction,
);
}, cappedDelay);
return;

View File

@@ -5,12 +5,11 @@ import { parseAnthropicTokenLimitError } from "./parser"
import { executeCompact, getLastAssistant } from "./executor"
import { log } from "../../shared/logger"
export interface AnthropicContextWindowLimitRecoveryOptions {
export interface AnthropicAutoCompactOptions {
experimental?: ExperimentalConfig
dcpForCompaction?: boolean
}
function createRecoveryState(): AutoCompactState {
function createAutoCompactState(): AutoCompactState {
return {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
@@ -23,10 +22,9 @@ function createRecoveryState(): AutoCompactState {
}
}
export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput, options?: AnthropicContextWindowLimitRecoveryOptions) {
const autoCompactState = createRecoveryState()
export function createAnthropicAutoCompactHook(ctx: PluginInput, options?: AnthropicAutoCompactOptions) {
const autoCompactState = createAutoCompactState()
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
@@ -83,8 +81,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
autoCompactState,
ctx.client,
ctx.directory,
experimental,
dcpForCompaction
experimental
)
}, 300)
}
@@ -143,8 +140,7 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
autoCompactState,
ctx.client,
ctx.directory,
experimental,
dcpForCompaction
experimental
)
}
}

View File

@@ -23,19 +23,6 @@ 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")
@@ -46,8 +33,14 @@ function findCommentCheckerPathSync(): string | null {
debugLog("found binary in main package:", binaryPath)
return binaryPath
}
} catch (err) {
debugLog("main package not installed or resolution failed:", err)
} catch {
debugLog("main package not installed")
}
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
debugLog("found binary in cache:", cachedPath)
return cachedPath
}
debugLog("no binary found in known locations")

View File

@@ -32,16 +32,9 @@ const PLATFORM_MAP: Record<string, PlatformInfo> = {
/**
* Get the cache directory for oh-my-opencode binaries.
* On Windows: Uses %LOCALAPPDATA% or %APPDATA% (Windows conventions)
* On Unix: Follows XDG Base Directory Specification
* 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")

View File

@@ -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 { createAnthropicContextWindowLimitRecoveryHook, type AnthropicContextWindowLimitRecoveryOptions } from "./anthropic-context-window-limit-recovery";
export { createAnthropicAutoCompactHook, type AnthropicAutoCompactOptions } from "./anthropic-auto-compact";
export { createPreemptiveCompactionHook, type PreemptiveCompactionOptions, type SummarizeContext, type BeforeSummarizeCallback } from "./preemptive-compaction";
export { createCompactionContextInjector } from "./compaction-context-injector";
export { createThinkModeHook } from "./think-mode";
@@ -22,4 +22,3 @@ 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";

View File

@@ -82,7 +82,7 @@ export function createPreemptiveCompactionHook(
const experimental = options?.experimental
const onBeforeSummarize = options?.onBeforeSummarize
const getModelLimit = options?.getModelLimit
const enabled = experimental?.preemptive_compaction === true
const enabled = experimental?.preemptive_compaction !== false
const threshold = experimental?.preemptive_compaction_threshold ?? DEFAULT_THRESHOLD
if (!enabled) {

View File

@@ -1,5 +0,0 @@
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"

View File

@@ -1,390 +0,0 @@
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 transcriptPath = join(TEST_DIR, "transcript.jsonl")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
writeFileSync(transcriptPath, JSON.stringify({ content: "Task done <promise>COMPLETE</promise>" }))
// #when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #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>")
})
})
})

View File

@@ -1,275 +0,0 @@
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"
import { getTranscriptPath as getDefaultTranscriptPath } from "../claude-code-hooks/transcript"
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
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
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
}
// Generate transcript path from sessionID - OpenCode doesn't pass it in event properties
const transcriptPath = getTranscriptPath(sessionID)
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,
}
}

View File

@@ -1,113 +0,0 @@
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
}

View File

@@ -1,16 +0,0 @@
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
getTranscriptPath?: (sessionId: string) => string
}

View File

@@ -1,359 +0,0 @@
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")
})
})
})

View File

@@ -1,325 +0,0 @@
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)
})
})
})

View File

@@ -1,67 +1,3 @@
/**
* 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> = {
@@ -71,9 +7,6 @@ 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",
@@ -81,20 +14,42 @@ 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(Object.values(HIGH_VARIANT_MAP))
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",
])
export const THINKING_CONFIGS = {
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
anthropic: {
thinking: {
type: "enabled",
@@ -127,59 +82,42 @@ export const THINKING_CONFIGS = {
},
},
},
openai: {
reasoning_effort: "high",
},
} as const satisfies Record<string, Record<string, unknown>>
}
const THINKING_CAPABLE_MODELS = {
const THINKING_CAPABLE_MODELS: Record<string, string[]> = {
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
"amazon-bedrock": ["claude", "anthropic"],
google: ["gemini-2", "gemini-3"],
"google-vertex": ["gemini-2", "gemini-3"],
openai: ["gpt-5", "o1", "o3"],
} as const satisfies Record<string, readonly string[]>
}
export function getHighVariant(modelID: string): string | null {
const normalized = normalizeModelID(modelID)
if (ALREADY_HIGH.has(normalized)) {
if (ALREADY_HIGH.has(modelID)) {
return null
}
return HIGH_VARIANT_MAP[normalized] ?? null
return HIGH_VARIANT_MAP[modelID] ?? null
}
export function isAlreadyHighVariant(modelID: string): boolean {
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
return ALREADY_HIGH.has(modelID) || modelID.endsWith("-high")
}
export function getThinkingConfig(
providerID: string,
modelID: string
): Record<string, unknown> | null {
const normalized = normalizeModelID(modelID)
if (isAlreadyHighVariant(normalized)) {
if (isAlreadyHighVariant(modelID)) {
return null
}
const resolvedProvider = resolveProvider(providerID, modelID)
const config = THINKING_CONFIGS[providerID]
const capablePatterns = THINKING_CAPABLE_MODELS[providerID]
if (!isThinkingProvider(resolvedProvider)) {
if (!config || !capablePatterns) {
return null
}
const config = THINKING_CONFIGS[resolvedProvider]
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
const modelLower = normalized.toLowerCase()
const modelLower = modelID.toLowerCase()
const isCapable = capablePatterns.some((pattern) =>
modelLower.includes(pattern.toLowerCase())
)

View File

@@ -24,7 +24,7 @@ interface ToolOutputTruncatorOptions {
export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOutputTruncatorOptions) {
const truncator = createDynamicTruncator(ctx)
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? false
const truncateAll = options?.experimental?.truncate_all_tool_outputs ?? true
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },

View File

@@ -12,7 +12,7 @@ import {
createEmptyTaskResponseDetectorHook,
createThinkModeHook,
createClaudeCodeHooksHook,
createAnthropicContextWindowLimitRecoveryHook,
createAnthropicAutoCompactHook,
createPreemptiveCompactionHook,
createCompactionContextInjector,
createRulesInjectorHook,
@@ -24,7 +24,6 @@ import {
createInteractiveBashSessionHook,
createEmptyMessageSanitizerHook,
createThinkingBlockValidatorHook,
createRalphLoopHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
@@ -34,18 +33,6 @@ import {
loadOpencodeProjectCommands,
} from "./features/claude-code-command-loader";
import { loadBuiltinCommands } from "./features/builtin-commands";
import {
loadUserSkills,
loadProjectSkills,
loadOpencodeGlobalSkills,
loadOpencodeProjectSkills,
discoverUserClaudeSkills,
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
mergeSkills,
} from "./features/opencode-skill-loader";
import { createBuiltinSkills } from "./features/builtin-skills";
import {
loadUserAgents,
@@ -57,7 +44,7 @@ import {
setMainSession,
getMainSessionID,
} from "./features/claude-code-session-state";
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, interactive_bash, getTmuxPath } from "./tools";
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, interactive_bash, getTmuxPath } from "./tools";
import { BackgroundManager } from "./features/background-agent";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
@@ -273,11 +260,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
});
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
? createAnthropicContextWindowLimitRecoveryHook(ctx, {
experimental: pluginConfig.experimental,
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
})
const anthropicAutoCompact = isHookEnabled("anthropic-auto-compact")
? createAnthropicAutoCompactHook(ctx, { experimental: pluginConfig.experimental })
: null;
const compactionContextInjector = createCompactionContextInjector();
const preemptiveCompaction = createPreemptiveCompactionHook(ctx, {
@@ -314,10 +298,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createThinkingBlockValidatorHook()
: null;
const ralphLoop = isHookEnabled("ralph-loop")
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
: null;
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -336,17 +316,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
const lookAt = createLookAt(ctx);
const builtinSkills = createBuiltinSkills();
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
const mergedSkills = mergeSkills(
builtinSkills,
pluginConfig.skills,
includeClaudeSkills ? discoverUserClaudeSkills() : [],
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
discoverOpencodeProjectSkills(),
);
const skillTool = createSkillTool({ skills: mergedSkills });
const googleAuthHooks = pluginConfig.google_auth !== false
? await createGoogleAntigravityAuthPlugin(ctx)
@@ -362,46 +331,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
...backgroundTools,
call_omo_agent: callOmoAgent,
look_at: lookAt,
skill: skillTool,
...(tmuxAvailable ? { interactive_bash } : {}),
},
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output);
await keywordDetector?.["chat.message"]?.(input, output);
if (ralphLoop) {
const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
const promptText = parts
?.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n")
.trim() || "";
const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") &&
promptText.includes("<user-task>");
const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
if (isRalphLoopTemplate) {
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i);
const rawTask = taskMatch?.[1]?.trim() || "";
const quotedMatch = rawTask.match(/^["'](.+?)["']/);
const prompt = quotedMatch?.[1] || rawTask.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i);
const promiseMatch = rawTask.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
log("[ralph-loop] Starting loop from chat.message", { sessionID: input.sessionID, prompt });
ralphLoop.startLoop(input.sessionID, prompt, {
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
completionPromise: promiseMatch?.[1],
});
} else if (isCancelRalphTemplate) {
log("[ralph-loop] Cancelling loop from chat.message", { sessionID: input.sessionID });
ralphLoop.cancelLoop(input.sessionID);
}
}
},
"experimental.chat.messages.transform": async (
@@ -588,25 +523,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const systemCommands = config.command ?? {};
const projectCommands = (pluginConfig.claude_code?.commands ?? true) ? loadProjectCommands() : {};
const opencodeProjectCommands = loadOpencodeProjectCommands();
const userSkills = (pluginConfig.claude_code?.skills ?? true) ? loadUserSkills() : {};
const projectSkills = (pluginConfig.claude_code?.skills ?? true) ? loadProjectSkills() : {};
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
const opencodeProjectSkills = loadOpencodeProjectSkills();
config.command = {
...builtinCommands,
...userCommands,
...userSkills,
...opencodeGlobalCommands,
...opencodeGlobalSkills,
...systemCommands,
...projectCommands,
...projectSkills,
...opencodeProjectCommands,
...opencodeProjectSkills,
...pluginComponents.commands,
...pluginComponents.skills,
};
},
@@ -621,11 +545,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await directoryReadmeInjector?.event(input);
await rulesInjector?.event(input);
await thinkMode?.event(input);
await anthropicContextWindowLimitRecovery?.event(input);
await anthropicAutoCompact?.event(input);
await preemptiveCompaction?.event(input);
await agentUsageReminder?.event(input);
await interactiveBashSession?.event(input);
await ralphLoop?.event(input);
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
@@ -692,28 +615,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
...(isExploreOrLibrarian ? { call_omo_agent: false } : {}),
};
}
if (ralphLoop && input.tool === "slashcommand") {
const args = output.args as { command?: string } | undefined;
const command = args?.command?.replace(/^\//, "").toLowerCase();
const sessionID = input.sessionID || getMainSessionID();
if (command === "ralph-loop" && sessionID) {
const rawArgs = args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
const taskMatch = rawArgs.match(/^["'](.+?)["']/);
const prompt = taskMatch?.[1] || rawArgs.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
ralphLoop.startLoop(sessionID, prompt, {
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
completionPromise: promiseMatch?.[1],
});
} else if (command === "cancel-ralph" && sessionID) {
ralphLoop.cancelLoop(sessionID);
}
}
},
"tool.execute.after": async (input, output) => {

View File

@@ -29,7 +29,6 @@ import {
} from "./session-manager"
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export { createSkillTool } from "./skill"
export { getTmuxPath } from "./interactive-bash/utils"
import {

View File

@@ -13,6 +13,4 @@ export const BLOCKED_TMUX_SUBCOMMANDS = [
export const INTERACTIVE_BASH_DESCRIPTION = `Execute tmux commands. Use "omo-{name}" session pattern.
For: server processes, long-running tasks, background jobs, interactive CLI tools.
Blocked (use bash instead): capture-pane, save-buffer, show-buffer, pipe-pane.`

View File

@@ -1,3 +1,3 @@
export const MULTIMODAL_LOOKER_AGENT = "multimodal-looker" as const
export const LOOK_AT_DESCRIPTION = `Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.`
export const LOOK_AT_DESCRIPTION = `Analyze media files (PDFs, images, diagrams) via Gemini 2.5 Flash in separate context. Saves main context tokens.`

View File

@@ -11,34 +11,20 @@ function inferMimeType(filePath: string): string {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".heic": "image/heic",
".heif": "image/heif",
".mp4": "video/mp4",
".mpeg": "video/mpeg",
".mpg": "video/mpeg",
".mov": "video/mov",
".avi": "video/avi",
".flv": "video/x-flv",
".webm": "video/webm",
".wmv": "video/wmv",
".3gpp": "video/3gpp",
".3gp": "video/3gpp",
".wav": "audio/wav",
".mp3": "audio/mp3",
".aiff": "audio/aiff",
".aac": "audio/aac",
".ogg": "audio/ogg",
".flac": "audio/flac",
".svg": "image/svg+xml",
".bmp": "image/bmp",
".ico": "image/x-icon",
".pdf": "application/pdf",
".txt": "text/plain",
".csv": "text/csv",
".md": "text/md",
".html": "text/html",
".md": "text/markdown",
".json": "application/json",
".xml": "application/xml",
".html": "text/html",
".css": "text/css",
".js": "text/javascript",
".py": "text/x-python",
".ts": "text/typescript",
}
return mimeTypes[ext] || "application/octet-stream"
}

View File

@@ -1,5 +1,4 @@
import { extname, resolve } from "path"
import { fileURLToPath } from "node:url"
import { existsSync, readFileSync, writeFileSync } from "fs"
import { LSPClient, lspManager } from "./client"
import { findServerForExtension } from "./config"
@@ -42,10 +41,6 @@ export function findWorkspaceRoot(filePath: string): string {
return require("path").dirname(resolve(filePath))
}
export function uriToPath(uri: string): string {
return fileURLToPath(uri)
}
export function formatServerLookupError(result: Exclude<ServerLookupResult, { status: "found" }>): string {
if (result.status === "not_installed") {
const { server, installHint } = result
@@ -77,6 +72,7 @@ export function formatServerLookupError(result: Exclude<ServerLookupResult, { st
` "extensions": ["${result.extension}"]`,
` }`,
` }`,
` }`,
].join("\n")
}
@@ -135,13 +131,13 @@ export function formatHoverResult(result: HoverResult | null): string {
export function formatLocation(loc: Location | LocationLink): string {
if ("targetUri" in loc) {
const uri = uriToPath(loc.targetUri)
const uri = loc.targetUri.replace("file://", "")
const line = loc.targetRange.start.line + 1
const char = loc.targetRange.start.character
return `${uri}:${line}:${char}`
}
const uri = uriToPath(loc.uri)
const uri = loc.uri.replace("file://", "")
const line = loc.range.start.line + 1
const char = loc.range.start.character
return `${uri}:${line}:${char}`
@@ -257,7 +253,7 @@ export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string {
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri)
const filePath = uri.replace("file://", "")
lines.push(`File: ${filePath}`)
for (const textEdit of edits) {
lines.push(formatTextEdit(textEdit))
@@ -276,7 +272,7 @@ export function formatWorkspaceEdit(edit: WorkspaceEdit | null): string {
lines.push(`Delete: ${change.uri}`)
}
} else {
const filePath = uriToPath(change.textDocument.uri)
const filePath = change.textDocument.uri.replace("file://", "")
lines.push(`File: ${filePath}`)
for (const textEdit of change.edits) {
lines.push(formatTextEdit(textEdit))
@@ -374,7 +370,7 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
if (edit.changes) {
for (const [uri, edits] of Object.entries(edit.changes)) {
const filePath = uriToPath(uri)
const filePath = uri.replace("file://", "")
const applyResult = applyTextEditsToFile(filePath, edits)
if (applyResult.success) {
@@ -392,7 +388,7 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
if ("kind" in change) {
if (change.kind === "create") {
try {
const filePath = uriToPath(change.uri)
const filePath = change.uri.replace("file://", "")
writeFileSync(filePath, "", "utf-8")
result.filesModified.push(filePath)
} catch (err) {
@@ -401,8 +397,8 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
}
} else if (change.kind === "rename") {
try {
const oldPath = uriToPath(change.oldUri)
const newPath = uriToPath(change.newUri)
const oldPath = change.oldUri.replace("file://", "")
const newPath = change.newUri.replace("file://", "")
const content = readFileSync(oldPath, "utf-8")
writeFileSync(newPath, content, "utf-8")
require("fs").unlinkSync(oldPath)
@@ -413,7 +409,7 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
}
} else if (change.kind === "delete") {
try {
const filePath = uriToPath(change.uri)
const filePath = change.uri.replace("file://", "")
require("fs").unlinkSync(filePath)
result.filesModified.push(filePath)
} catch (err) {
@@ -422,7 +418,7 @@ export function applyWorkspaceEdit(edit: WorkspaceEdit | null): ApplyResult {
}
}
} else {
const filePath = uriToPath(change.textDocument.uri)
const filePath = change.textDocument.uri.replace("file://", "")
const applyResult = applyTextEditsToFile(filePath, change.edits)
if (applyResult.success) {

View File

@@ -1,8 +0,0 @@
export const TOOL_NAME = "skill" as const
export const TOOL_DESCRIPTION_NO_SKILLS = "Load a skill to get detailed instructions for a specific task. No skills are currently available."
export const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.
Skills provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's description.`

View File

@@ -1,3 +0,0 @@
export * from "./constants"
export * from "./types"
export { skill, createSkillTool } from "./tools"

View File

@@ -1,89 +0,0 @@
import { dirname } from "node:path"
import { readFileSync } from "node:fs"
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants"
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import { parseFrontmatter } from "../../shared/frontmatter"
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
name: skill.name,
description: skill.definition.description || "",
location: skill.path,
scope: skill.scope,
license: skill.license,
compatibility: skill.compatibility,
metadata: skill.metadata,
allowedTools: skill.allowedTools,
}
}
function formatSkillsXml(skills: SkillInfo[]): string {
if (skills.length === 0) return ""
const skillsXml = skills.map(skill => {
const lines = [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
]
if (skill.compatibility) {
lines.push(` <compatibility>${skill.compatibility}</compatibility>`)
}
lines.push(" </skill>")
return lines.join("\n")
}).join("\n")
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
}
function extractSkillBody(skill: LoadedSkill): string {
if (skill.path) {
const content = readFileSync(skill.path, "utf-8")
const { body } = parseFrontmatter(content)
return body.trim()
}
const templateMatch = skill.definition.template?.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
}
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
const skillInfos = skills.map(loadedSkillToInfo)
const description = skillInfos.length === 0
? TOOL_DESCRIPTION_NO_SKILLS
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
return tool({
description,
args: {
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
},
async execute(args: SkillArgs) {
const skill = options.skills
? skills.find(s => s.name === args.name)
: skills.find(s => s.name === args.name)
if (!skill) {
const available = skills.map(s => s.name).join(", ")
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
}
const body = extractSkillBody(skill)
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
return [
`## Skill: ${skill.name}`,
"",
`**Base directory**: ${dir}`,
"",
body,
].join("\n")
},
})
}
export const skill = createSkillTool()

View File

@@ -1,23 +0,0 @@
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
export interface SkillArgs {
name: string
}
export interface SkillInfo {
name: string
description: string
location?: string
scope: SkillScope
license?: string
compatibility?: string
metadata?: Record<string, string>
allowedTools?: string[]
}
export interface SkillLoadOptions {
/** When true, only load from OpenCode paths (.opencode/skill/, ~/.config/opencode/skill/) */
opencodeOnly?: boolean
/** Pre-merged skills to use instead of discovering */
skills?: LoadedSkill[]
}

View File

@@ -4,7 +4,6 @@ import { join, basename, dirname } from "path"
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import type { CommandScope, CommandMetadata, CommandInfo } from "./types"
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
@@ -65,30 +64,8 @@ function discoverCommandsSync(): CommandInfo[] {
return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands]
}
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
return {
name: skill.name,
path: skill.path,
metadata: {
name: skill.name,
description: skill.definition.description || "",
argumentHint: skill.definition.argumentHint,
model: skill.definition.model,
agent: skill.definition.agent,
subtask: skill.definition.subtask,
},
content: skill.definition.template,
scope: skill.scope,
}
}
const availableCommands = discoverCommandsSync()
const availableSkills = discoverAllSkills()
const availableItems = [
...availableCommands,
...availableSkills.map(skillToCommandInfo),
]
const commandListForDescription = availableItems
const commandListForDescription = availableCommands
.map((cmd) => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
@@ -124,41 +101,61 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
sections.push("---\n")
sections.push("## Command Instructions\n")
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
const commandDir = dirname(cmd.path)
const withFileRefs = await resolveFileReferencesInText(cmd.content, commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
return sections.join("\n")
}
function formatCommandList(items: CommandInfo[]): string {
if (items.length === 0) {
return "No commands or skills found."
function formatCommandList(commands: CommandInfo[]): string {
if (commands.length === 0) {
return "No commands found."
}
const lines = ["# Available Commands & Skills\n"]
const lines = ["# Available Commands\n"]
for (const cmd of items) {
for (const cmd of commands) {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
lines.push(
`- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})`
)
}
lines.push(`\n**Total**: ${items.length} items`)
lines.push(`\n**Total**: ${commands.length} commands`)
return lines.join("\n")
}
export const slashcommand: ToolDefinition = tool({
description: `Load a skill to get detailed instructions for a specific task.
description: `Execute a slash command within the main conversation.
Skills provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's description.
When you use this tool, the slash command gets expanded to a full prompt that provides detailed instructions on how to complete the task.
<available_skills>
${commandListForDescription}
</available_skills>`,
How slash commands work:
- Invoke commands using this tool with the command name (without arguments)
- The command's prompt will expand and provide detailed instructions
- Arguments from user input should be passed separately
Important:
- Only use commands listed in Available Commands below
- Do not invoke a command that is already running
- **CRITICAL**: When user's message starts with '/' (e.g., "/commit", "/plan"), you MUST immediately invoke this tool with that command. Do NOT attempt to handle the command manually.
Commands are loaded from (priority order, highest wins):
- .opencode/command/ (opencode-project - OpenCode project-specific commands)
- ./.claude/commands/ (project - Claude Code project-specific commands)
- ~/.config/opencode/command/ (opencode - OpenCode global commands)
- $CLAUDE_CONFIG_DIR/commands/ or ~/.claude/commands/ (user - Claude Code global commands)
Each command is a markdown file with:
- YAML frontmatter: description, argument-hint, model, agent, subtask (optional)
- Markdown body: The command instructions/prompt
- File references: @path/to/file (relative to command file location)
- Shell injection: \`!\`command\`\` (executes and injects output)
Available Commands:
${commandListForDescription}`,
args: {
command: tool.schema
@@ -170,19 +167,14 @@ ${commandListForDescription}
async execute(args) {
const commands = discoverCommandsSync()
const skills = discoverAllSkills()
const allItems = [
...commands,
...skills.map(skillToCommandInfo),
]
if (!args.command) {
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
return formatCommandList(commands) + "\n\nProvide a command name to execute."
}
const cmdName = args.command.replace(/^\//, "")
const exactMatch = allItems.find(
const exactMatch = commands.find(
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
)
@@ -190,7 +182,7 @@ ${commandListForDescription}
return await formatLoadedCommand(exactMatch)
}
const partialMatches = allItems.filter((cmd) =>
const partialMatches = commands.filter((cmd) =>
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
)
@@ -198,14 +190,14 @@ ${commandListForDescription}
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
formatCommandList(commands)
)
}
return (
`Command or skill "/${cmdName}" not found.\n\n` +
formatCommandList(allItems) +
"\n\nTry a different name."
`Command "/${cmdName}" not found.\n\n` +
formatCommandList(commands) +
"\n\nTry a different command name."
)
},
})

View File

@@ -1,4 +1,4 @@
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
export interface CommandMetadata {
name: string
@@ -11,8 +11,8 @@ export interface CommandMetadata {
export interface CommandInfo {
name: string
path?: string
path: string
metadata: CommandMetadata
content?: string
content: string
scope: CommandScope
}