Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83676b36cf | ||
|
|
398075f5df | ||
|
|
d4347e829d | ||
|
|
980b685393 | ||
|
|
b5c1cfb57f | ||
|
|
cd97572d0a | ||
|
|
b9ec4c7c4a | ||
|
|
2064568124 | ||
|
|
ad44af9d15 | ||
|
|
d331b484f9 | ||
|
|
4a38e70fa8 | ||
|
|
204ea319cb | ||
|
|
a2bfb5e556 | ||
|
|
f25f7ed0f5 | ||
|
|
29dbc0f57b | ||
|
|
544212fa9c | ||
|
|
f3eed731d6 | ||
|
|
6f1cabd3f4 | ||
|
|
15571d3d95 | ||
|
|
556262e791 | ||
|
|
375e7f715d | ||
|
|
5aa0ee125d | ||
|
|
d0b3be72c5 | ||
|
|
a10903def2 | ||
|
|
dc5a24ac3e | ||
|
|
9d13c6cff1 | ||
|
|
b78e564872 | ||
|
|
c709fafa25 | ||
|
|
5914a393ad | ||
|
|
4e5b3566a2 | ||
|
|
898d3e6175 | ||
|
|
21236d88a7 | ||
|
|
ea8ca1a100 | ||
|
|
66acb0e444 | ||
|
|
f7c8763462 | ||
|
|
ee2f390bf6 | ||
|
|
ae6495dc17 | ||
|
|
b8b8d14b1c | ||
|
|
7a10b24bbd | ||
|
|
258463a146 | ||
|
|
0f890c11c2 | ||
|
|
e81002ba43 | ||
|
|
a20f011014 | ||
|
|
48174ec25a | ||
|
|
26e77a0a89 | ||
|
|
a5c71473a5 | ||
|
|
aecfc77fb6 | ||
|
|
5a4261a607 | ||
|
|
6913613398 | ||
|
|
d27a1efd94 | ||
|
|
bc05fb6671 | ||
|
|
7937d72cbf | ||
|
|
fe11ba294c | ||
|
|
6b5a8263f9 | ||
|
|
65b00c9720 | ||
|
|
5ed031db63 | ||
|
|
0553676ab0 |
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior in oh-my-opencode
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues to avoid duplicates
|
||||
required: true
|
||||
- label: I am using the latest version of oh-my-opencode
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug in detail...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Configure oh-my-opencode with...
|
||||
2. Run command '...'
|
||||
3. See error...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what should happen...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output
|
||||
description: |
|
||||
**Required:** Run `bunx oh-my-opencode doctor` and paste the full output below.
|
||||
This helps us diagnose your environment and configuration.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
|
||||
Example:
|
||||
✓ OpenCode version: 1.0.150
|
||||
✓ oh-my-opencode version: 1.2.3
|
||||
✓ Plugin loaded successfully
|
||||
...
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Logs
|
||||
description: If applicable, add any error messages or logs
|
||||
placeholder: Paste error logs here...
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: If relevant, share your oh-my-opencode configuration (remove sensitive data)
|
||||
placeholder: |
|
||||
{
|
||||
"agents": { ... },
|
||||
"disabled_hooks": [ ... ]
|
||||
}
|
||||
render: json
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: Add any other context, screenshots, or information...
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Which operating system are you using?
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: opencode-version
|
||||
attributes:
|
||||
label: OpenCode Version
|
||||
description: Run `opencode --version` to get your version
|
||||
placeholder: "1.0.150"
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/PUwSMR9XNk
|
||||
about: Join our Discord server for real-time discussions and community support
|
||||
- name: Documentation
|
||||
url: https://github.com/code-yeongyu/oh-my-opencode#readme
|
||||
about: Read the comprehensive documentation and guides
|
||||
100
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
100
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for oh-my-opencode
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and discussions to avoid duplicates
|
||||
required: true
|
||||
- label: This feature request is specific to oh-my-opencode (not OpenCode core)
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: What problem does this feature solve? What's the use case?
|
||||
placeholder: |
|
||||
Describe the problem or limitation you're experiencing...
|
||||
Example: "As a user, I find it difficult to..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe how you'd like this feature to work
|
||||
placeholder: |
|
||||
Describe your proposed solution in detail...
|
||||
Example: "Add a new hook that..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
placeholder: |
|
||||
Describe any alternative solutions you've considered...
|
||||
Example: "I tried using X but it didn't work because..."
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output (Optional)
|
||||
description: |
|
||||
If relevant to your feature request, run `bunx oh-my-opencode doctor` and paste the output.
|
||||
This helps us understand your environment.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
(Optional for feature requests)
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, mockups, or examples
|
||||
placeholder: |
|
||||
Add any other context, screenshots, code examples, or links...
|
||||
Examples from other tools/projects are helpful!
|
||||
|
||||
- type: dropdown
|
||||
id: feature-type
|
||||
attributes:
|
||||
label: Feature Type
|
||||
description: What type of feature is this?
|
||||
options:
|
||||
- New Agent
|
||||
- New Hook
|
||||
- New Tool
|
||||
- New MCP Integration
|
||||
- Configuration Option
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Contribution
|
||||
description: Are you willing to contribute to this feature?
|
||||
options:
|
||||
- label: I'm willing to submit a PR for this feature
|
||||
- label: I can help with testing
|
||||
- label: I can help with documentation
|
||||
83
.github/ISSUE_TEMPLATE/general.yml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/general.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Question or Discussion
|
||||
description: Ask a question or start a discussion about oh-my-opencode
|
||||
title: "[Question]: "
|
||||
labels: ["question", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and discussions
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
- label: This is a question (not a bug report or feature request)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: What would you like to know or discuss?
|
||||
placeholder: |
|
||||
Ask your question in detail...
|
||||
|
||||
Examples:
|
||||
- How do I configure agent X to do Y?
|
||||
- What's the best practice for Z?
|
||||
- Why does feature A work differently than B?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Provide any relevant context or background
|
||||
placeholder: |
|
||||
What have you tried so far?
|
||||
What's your use case?
|
||||
Any relevant configuration or setup details?
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output (Optional)
|
||||
description: |
|
||||
If your question is about configuration or setup, run `bunx oh-my-opencode doctor` and paste the output.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
(Optional for questions)
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Question Category
|
||||
description: What is your question about?
|
||||
options:
|
||||
- Configuration
|
||||
- Agent Usage
|
||||
- Hook Behavior
|
||||
- Tool Usage
|
||||
- Installation/Setup
|
||||
- Best Practices
|
||||
- Performance
|
||||
- Integration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any other information that might be helpful
|
||||
placeholder: Links, screenshots, examples, etc.
|
||||
@@ -20,7 +20,7 @@ oh-my-opencode/
|
||||
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
|
||||
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
|
||||
│ ├── mcp/ # MCP configs: context7, grep_app
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (464 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
|
||||
@@ -26,6 +26,29 @@ First off, thanks for taking the time to contribute! This document provides guid
|
||||
|
||||
Be respectful, inclusive, and constructive. We're all here to make better tools together.
|
||||
|
||||
## Language Policy
|
||||
|
||||
**English is the primary language for all communications in this repository.**
|
||||
|
||||
This includes:
|
||||
- Issues and bug reports
|
||||
- Pull requests and code reviews
|
||||
- Documentation and comments
|
||||
- Discussions and community interactions
|
||||
|
||||
### Why English?
|
||||
|
||||
- **Global Accessibility**: English allows contributors from all regions to collaborate effectively
|
||||
- **Consistency**: A single language keeps discussions organized and searchable
|
||||
- **Open Source Best Practice**: Most successful open-source projects use English as the lingua franca
|
||||
|
||||
### Need Help with English?
|
||||
|
||||
If English isn't your first language, don't worry! We value your contributions regardless of perfect grammar. You can:
|
||||
- Use translation tools to help compose messages
|
||||
- Ask for help from other community members
|
||||
- Focus on clear, simple communication rather than perfect prose
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
@@ -89,7 +112,7 @@ oh-my-opencode/
|
||||
│ ├── agents/ # AI agents (OmO, oracle, librarian, explore, etc.)
|
||||
│ ├── hooks/ # 21 lifecycle hooks
|
||||
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, etc.
|
||||
│ ├── mcp/ # MCP server integrations (context7, websearch_exa, grep_app)
|
||||
│ ├── mcp/ # MCP server integrations (context7, grep_app)
|
||||
│ ├── features/ # Claude Code compatibility layers
|
||||
│ ├── config/ # Zod schemas and TypeScript types
|
||||
│ ├── auth/ # Google Antigravity OAuth
|
||||
|
||||
50
README.ja.md
50
README.ja.md
@@ -10,6 +10,7 @@
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discordコミュニティ](https://discord.gg/PUwSMR9XNk)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
|
||||
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | GitHubで[@code-yeongyu](https://github.com/code-yeongyu)をフォローして、他のプロジェクトもチェックしてください。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -189,13 +190,15 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||
|
||||
インストールするだけで、エージェントは以下のようなワークフローで働けるようになります:
|
||||
|
||||
1. バックグラウンドタスクとして Gemini 3 Pro にフロントエンドを書かせている間に、Claude Opus 4.5 がバックエンドを作成し、デバッグで詰まったら GPT 5.2 に助けを求めます。フロントエンドの実装完了報告が来たら、それを検証して出荷します。
|
||||
2. 何か調べる必要があれば、公式ドキュメント、コードベースの全履歴、GitHub に公開されている実装例まで徹底的に調査します。単なる grep だけでなく、内蔵された LSP ツールや AST-Grep まで駆使します。
|
||||
3. LLM に仕事を任せる際、コンテキスト管理の心配はもう不要です。私がやります。
|
||||
- OhMyOpenCode は複数のエージェントを積極的に活用し、コンテキストの負荷を軽減します。
|
||||
- **あなたのエージェントは今や開発チームのリードです。あなたは AI マネージャーです。**
|
||||
4. 頼んだ仕事が完了するまで止まりません。
|
||||
5. このプロジェクトについて深く知りたくない?大丈夫です。ただ 'ultrathink' と入力してください。
|
||||
1. Sisyphusは自分自身でファイルを探し回るような時間の無駄はしません。メインエージェントのコンテキストを軽量に保つため、より高速で安価なモデルへ並列でバックグラウンドタスクを飛ばし、自身の代わりに領域の調査を完了させます。
|
||||
1. SisyphusはリファクタリングにLSPを活用します。その方が確実で、安全、かつ的確だからです。
|
||||
1. UIに関わる重い作業が必要な場合、SisyphusはフロントエンドのタスクをGemini 3 Proに直接デリゲートします。
|
||||
1. もしSisyphusがループに陥ったり壁にぶつかったりしても、無駄に悩み続けることはありません。高IQな戦略的バックアップとしてGPT 5.2を呼び出します。
|
||||
1. 複雑なオープンソースフレームワークを扱っていますか?Sisyphusはサブエージェントを生成し、生のソースコードやドキュメントをリアルタイムで消化します。彼は完全なコンテキスト認識を持って動作します。
|
||||
1. Sisyphusがコメントに触れるとき、その存在意義を証明するか、さもなくば削除します。あなたのコードベースを常にクリーンに保ちます。
|
||||
1. Sisyphusは自身のTODOリストに縛られています。もし始めたことを終わらせられなければ、システムは彼を強制的に「bouldering」モードに戻します。あなたのタスクは、何があろうと完了します。
|
||||
1. 正直、ドキュメントなんて読む必要はありません。ただプロンプトを書いてください。「ultrawork」というキーワードを含めるだけで十分です。Sisyphusが構造を分析し、コンテキストを集め、外部のソースコードまで掘り下げ、仕事が100%完了するまでboulderingを続けます。
|
||||
1. ぶっちゃけ、「ultrawork」と打つのすら面倒ですよね。それなら「ulw」だけでOKです。ただulwと打ち、コーヒーでも飲んでいてください。仕事は終わっています。
|
||||
|
||||
このような機能が不要であれば、前述の通り、特定の機能だけを選んで使うことができます。
|
||||
|
||||
@@ -297,7 +300,7 @@ opencode auth login
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-antigravity-auth@1.1.2"
|
||||
"opencode-antigravity-auth@1.2.7"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -322,7 +325,7 @@ opencode auth login
|
||||
}
|
||||
```
|
||||
|
||||
**利用可能なモデル名**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
|
||||
**利用可能なモデル名**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
|
||||
|
||||
その後、認証を行います:
|
||||
|
||||
@@ -345,26 +348,19 @@ opencode auth login
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.1"
|
||||
"opencode-openai-codex-auth@4.3.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**重要**: 現在、公式 npm パッケージに 400 エラー (`"No tool call found for function call output with call_id"`) を引き起こすバグがあります。修正版がリリースされるまでは、**ホットフィックスブランチの使用を推奨します**。`~/.config/opencode/package.json` を修正してください:
|
||||
##### モデル設定
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
その後、`cd ~/.config/opencode && bun i` を実行してください。`opencode.json` ではバージョン指定なしで `"opencode-openai-codex-auth"` として使用します(`@4.1.0` は除外)。
|
||||
|
||||
#### 4.3.1 モデル設定
|
||||
`opencode.json` に完全なモデル設定も構成する必要があります。
|
||||
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json) から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
|
||||
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)(OpenCode v1.0.210+)または [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧バージョン)から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
|
||||
|
||||
**利用可能なモデル**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
|
||||
|
||||
**Variants** (OpenCode v1.0.210+): `--variant=<none|low|medium|high|xhigh>` オプションで推論強度を制御できます。
|
||||
|
||||
その後、認証を行います:
|
||||
|
||||
@@ -563,7 +559,6 @@ OpenCode セッション履歴をナビゲートおよび検索するための
|
||||
```
|
||||
- **Online**: プロジェクトのルールがすべてではありません。拡張機能のための内蔵 MCP を提供します:
|
||||
- **context7**: ライブラリの最新公式ドキュメントを取得
|
||||
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
|
||||
- **grep_app**: 数百万の公開 GitHub リポジトリから超高速コード検索(実装例を探すのに最適)
|
||||
|
||||
#### マルチモーダルを活用し、トークンは節約する
|
||||
@@ -655,7 +650,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
|
||||
| トグル | `false` の場合、ロードが無効になるパス | 影響を受けないもの |
|
||||
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内蔵 MCP (context7, websearch_exa) |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内蔵 MCP (context7, grep_app) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 内蔵エージェント (oracle, librarian 等) |
|
||||
@@ -928,17 +923,16 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
|
||||
### MCPs
|
||||
|
||||
コンテキスト7、Exa、grep.app MCP がデフォルトで有効になっています。
|
||||
Context7、grep.app MCP がデフォルトで有効になっています。
|
||||
|
||||
- **context7**: ライブラリの最新公式ドキュメントを取得
|
||||
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
|
||||
- **grep_app**: [grep.app](https://grep.app) を通じて数百万の公開 GitHub リポジトリから超高速コード検索
|
||||
|
||||
不要であれば、`~/.config/opencode/oh-my-opencode.json` または `.opencode/oh-my-opencode.json` の `disabled_mcps` を使用して無効化できます:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
|
||||
"disabled_mcps": ["context7", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
1047
README.ko.md
1047
README.ko.md
File diff suppressed because it is too large
Load Diff
54
README.md
54
README.md
@@ -10,6 +10,7 @@
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | Join our [Discord community](https://discord.gg/PUwSMR9XNk) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
|
||||
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | Follow [@code-yeongyu](https://github.com/code-yeongyu) on GitHub for more projects. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -41,7 +42,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -128,8 +129,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
oMoMoMoMoMo···
|
||||
|
||||
Meet Sisyphus: The Batteries-Included Agent that codes like you.
|
||||
|
||||
[Claude Code](https://www.claude.com/product/claude-code) is great.
|
||||
But if you're a hacker, you'll fall head over heels for [OpenCode](https://github.com/sst/opencode).
|
||||
@@ -197,8 +197,17 @@ Meet our main agent: Sisyphus (Opus 4.5 High). Below are the tools Sisyphus uses
|
||||
|
||||
Just by installing this, you make your agents to work like:
|
||||
|
||||
1. While Gemini 3 Pro writes the frontend as a background task, Claude Opus 4.5 handles the backend. Stuck debugging? Call GPT 5.2 for help. When the frontend reports done, verify and ship.
|
||||
2. Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
|
||||
1. Sisyphus doesn't waste time hunting for files himself; he keeps the main agent's context lean. Instead, he fires off background tasks to faster, cheaper models in parallel to map the territory for him.
|
||||
1. Sisyphus leverages LSP for refactoring; it's more deterministic, safer, and surgical.
|
||||
1. When the heavy lifting requires a UI touch, Sisyphus delegates frontend tasks directly to Gemini 3 Pro.
|
||||
1. If Sisyphus gets stuck in a loop or hits a wall, he doesn't keep banging his head—he calls GPT 5.2 for high-IQ strategic backup.
|
||||
1. Working with a complex open-source framework? Sisyphus spawns subagents to digest the raw source code and documentation in real-time. He operates with total contextual awareness.
|
||||
1. When Sisyphus touches comments, he either justifies their existence or nukes them. He keeps your codebase clean.
|
||||
1. Sisyphus is bound by his TODO list. If he doesn't finish what he started, the system forces him back into "bouldering" mode. Your task gets done, period.
|
||||
1. Honestly, don't even bother reading the docs. Just write your prompt. Include the 'ultrawork' keyword. Sisyphus will analyze the structure, gather the context, dig through external source code, and just keep bouldering until the job is 100% complete.
|
||||
1. Actually, typing 'ultrawork' is too much effort. Just type 'ulw'. Just ulw. Sip your coffee. Your work is done.
|
||||
|
||||
Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
|
||||
3. Stop worrying about context management when delegating to LLMs. I've got it covered.
|
||||
- OhMyOpenCode aggressively leverages multiple agents to lighten the context load.
|
||||
- **Your agent is now the dev team lead. You're the AI Manager.**
|
||||
@@ -321,7 +330,7 @@ First, add the opencode-antigravity-auth plugin:
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-antigravity-auth@1.1.2"
|
||||
"opencode-antigravity-auth@1.2.7"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -346,7 +355,7 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
|
||||
}
|
||||
```
|
||||
|
||||
**Available model names**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
|
||||
**Available model names**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
|
||||
|
||||
Then authenticate:
|
||||
|
||||
@@ -369,26 +378,19 @@ First, add the opencode-openai-codex-auth plugin:
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.1"
|
||||
"opencode-openai-codex-auth@4.3.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Important**: The official npm package currently has a bug causing 400 errors (`"No tool call found for function call output with call_id"`). **Use the hotfix branch** until fixed. Edit `~/.config/opencode/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run `cd ~/.config/opencode && bun i`. In `opencode.json`, use `"opencode-openai-codex-auth"` without the version suffix.
|
||||
|
||||
##### Model Configuration
|
||||
|
||||
You'll also need full model settings in `opencode.json`.
|
||||
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json), and merge carefully to avoid breaking the user's existing setup.
|
||||
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json) (for OpenCode v1.0.210+) or [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json) (for older versions), and merge carefully to avoid breaking the user's existing setup.
|
||||
|
||||
**Available models**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
|
||||
|
||||
**Variants** (OpenCode v1.0.210+): Use `--variant=<none|low|medium|high|xhigh>` for reasoning effort control.
|
||||
|
||||
Then authenticate:
|
||||
|
||||
@@ -581,8 +583,8 @@ These tools enable agents to reference previous conversations and maintain conti
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Online**: Project rules aren't everything. Built-in MCPs for extended capabilities:
|
||||
- **websearch**: Real-time web search powered by [Exa AI](https://exa.ai)
|
||||
- **context7**: Official documentation lookup
|
||||
- **websearch_exa**: Real-time web search
|
||||
- **grep_app**: Ultra-fast code search across public GitHub repos (great for finding implementation examples)
|
||||
|
||||
#### Be Multimodal. Save Tokens.
|
||||
@@ -694,7 +696,7 @@ Disable specific Claude Code compatibility features with the `claude_code` confi
|
||||
|
||||
| Toggle | When `false`, stops loading from... | Unaffected |
|
||||
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, websearch_exa) |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, grep_app) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
|
||||
@@ -983,17 +985,17 @@ Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `sessio
|
||||
|
||||
### MCPs
|
||||
|
||||
Context7, Exa, and grep.app MCP enabled by default.
|
||||
Exa, Context7 and grep.app MCP enabled by default.
|
||||
|
||||
- **websearch**: Real-time web search powered by [Exa AI](https://exa.ai) - searches the web and returns relevant content
|
||||
- **context7**: Fetches up-to-date official documentation for libraries
|
||||
- **websearch_exa**: Real-time web search powered by Exa AI
|
||||
- **grep_app**: Ultra-fast code search across millions of public GitHub repositories via [grep.app](https://grep.app)
|
||||
|
||||
Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
|
||||
"disabled_mcps": ["websearch", "context7", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 加入我们的 [Discord 社区](https://discord.gg/PUwSMR9XNk),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
|
||||
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | 在 GitHub 上关注 [@code-yeongyu](https://github.com/code-yeongyu),了解更多项目。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -191,13 +192,15 @@ oMoMoMoMoMo···
|
||||
|
||||
装完之后,你的 Agent 画风是这样的:
|
||||
|
||||
1. 后台让 Gemini 3 Pro 写前端,Claude Opus 4.5 同时在写后端。调试卡住了?喊 GPT 5.2 过来救场。前端说搞定了,你验货,上线。
|
||||
2. 要查资料?它会把官方文档、整个代码历史、GitHub 上的公开实现翻个底朝天——靠的不只是 grep,还有内置 LSP 和 AST-Grep。
|
||||
3. 别再操心什么上下文管理了。我包了。
|
||||
- OhMyOpenCode 疯狂压榨多个 Agent,把上下文负担降到最低。
|
||||
- **现在的 Agent 才是开发组长,你?你是 AI 经理。**
|
||||
4. 活儿没干完,绝对不收工。
|
||||
5. 不想研究这么深?没事。输入 "ultrathink" 就完事了。
|
||||
1. Sisyphus 从不把时间浪费在苦哈哈地找文件上,他时刻保持主 Agent 的 Context 精简干练。相反,他会并行启动一堆又快又便宜的背景任务模型,帮他先探路,摸清代码全貌。
|
||||
1. Sisyphus 善用 LSP 进行重构;这种方式更具确定性,更安全,且手术刀般精准。
|
||||
1. 遇到需要 UI 润色的重活儿时,Sisyphus 会直接把前端任务甩给 Gemini 3 Pro 处理。
|
||||
1. 如果 Sisyphus 陷入死循环或碰了壁,他绝不会在那儿死磕——他会呼叫 GPT 5.2 提供高智商的战略支援。
|
||||
1. 在处理复杂的开源框架?Sisyphus 会派生出 Subagents 实时消化源码和文档。他是在拥有全局 Context 意识的情况下进行操作的。
|
||||
1. 当 Sisyphus 动到注释时,他要么证明其存在的价值,要么直接干掉。他只负责保持你的代码库干净整洁。
|
||||
1. Sisyphus 受 TODO 列表的绝对约束。如果活儿没干完,系统会强行把他踢回"推石头(bouldering)"模式。一句话,任务必须搞定。
|
||||
1. 说实话,连文档都别费劲读了。直接写你的 Prompt,带上 'ultrawork' 关键字。Sisyphus 会自动分析结构、抓取 Context、深度挖掘外部源码,然后就这么一直"推石头",直到任务 100% 彻底完成。
|
||||
1. 其实,输入 'ultrawork' 都挺费劲的。直接打 'ulw' 就行。就打 ulw。喝你的咖啡去吧,活儿已经帮你干完了。
|
||||
|
||||
如果你不需要这全套服务,前面说了,挑你喜欢的用。
|
||||
|
||||
@@ -305,7 +308,7 @@ opencode auth login
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-antigravity-auth@1.1.2"
|
||||
"opencode-antigravity-auth@1.2.7"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -330,7 +333,7 @@ opencode auth login
|
||||
}
|
||||
```
|
||||
|
||||
**可用模型名**:`google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
|
||||
**可用模型名**:`google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
|
||||
|
||||
然后认证:
|
||||
|
||||
@@ -353,26 +356,19 @@ opencode auth login
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-openai-codex-auth@4.1.1"
|
||||
"opencode-openai-codex-auth@4.3.0"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**重要**:现在官方 npm 包有个 bug 会报 400 错(`"No tool call found for function call output with call_id"`)。修复版出来前,**一定要用 hotfix 分支**。改一下 `~/.config/opencode/package.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后跑 `cd ~/.config/opencode && bun i`。在 `opencode.json` 里用 `"opencode-openai-codex-auth"`(别带版本号)。
|
||||
|
||||
##### 模型配置
|
||||
|
||||
要在 `opencode.json` 里配完整的模型设置。
|
||||
去读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json) 抄 provider/models 配置,动脑子合并,别搞炸了。
|
||||
去读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)(OpenCode v1.0.210+)或 [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧版本)抄 provider/models 配置,动脑子合并,别搞炸了。
|
||||
|
||||
**可用模型**:`openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
|
||||
|
||||
**Variants**(OpenCode v1.0.210+):用 `--variant=<none|low|medium|high|xhigh>` 控制推理强度。
|
||||
|
||||
然后认证:
|
||||
|
||||
@@ -567,7 +563,6 @@ OhMyOpenCode 让这些成为可能。
|
||||
```
|
||||
- **在线资源**:项目里的规矩不够用?内置 MCP 来凑:
|
||||
- **context7**:查最新的官方文档
|
||||
- **websearch_exa**:Exa AI 实时搜网
|
||||
- **grep_app**:用 [grep.app](https://grep.app) 在几百万个 GitHub 仓库里秒搜代码(找抄作业的例子神器)
|
||||
|
||||
#### 多模态全开,Token 省着用
|
||||
@@ -659,7 +654,7 @@ Oh My OpenCode 会扫这些地方:
|
||||
|
||||
| 开关 | 设为 `false` 就停用的路径 | 不受影响的 |
|
||||
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内置 MCP(context7、websearch_exa) |
|
||||
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内置 MCP(context7、grep_app) |
|
||||
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
|
||||
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
|
||||
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 内置 Agent(oracle、librarian 等) |
|
||||
@@ -932,17 +927,16 @@ Sisyphus Agent 也能自定义:
|
||||
|
||||
### MCPs
|
||||
|
||||
默认送你 Context7、Exa 和 grep.app MCP。
|
||||
默认送你 Context7 和 grep.app MCP。
|
||||
|
||||
- **context7**:查最新的官方文档
|
||||
- **websearch_exa**:Exa AI 实时搜网
|
||||
- **grep_app**:[grep.app](https://grep.app) 极速搜 GitHub 代码
|
||||
|
||||
不想要?在 `~/.config/opencode/oh-my-opencode.json` 或 `.opencode/oh-my-opencode.json` 的 `disabled_mcps` 里关掉:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
|
||||
"disabled_mcps": ["context7", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"websearch_exa",
|
||||
"context7",
|
||||
"grep_app"
|
||||
]
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"disabled_agents": {
|
||||
@@ -1662,6 +1658,35 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"background_task": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defaultConcurrency": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"providerConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.12.3",
|
||||
"version": "2.14.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -199,6 +199,70 @@
|
||||
"created_at": "2026-01-04T17:42:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 484
|
||||
},
|
||||
{
|
||||
"name": "popododo0720",
|
||||
"id": 78542988,
|
||||
"comment_id": 3708870772,
|
||||
"created_at": "2026-01-05T04:07:35Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 477
|
||||
},
|
||||
{
|
||||
"name": "raydocs",
|
||||
"id": 139067258,
|
||||
"comment_id": 3709269581,
|
||||
"created_at": "2026-01-05T07:39:43Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 499
|
||||
},
|
||||
{
|
||||
"name": "luosky",
|
||||
"id": 307601,
|
||||
"comment_id": 3710103143,
|
||||
"created_at": "2026-01-05T11:46:40Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 512
|
||||
},
|
||||
{
|
||||
"name": "jkoelker",
|
||||
"id": 75854,
|
||||
"comment_id": 3713015728,
|
||||
"created_at": "2026-01-06T03:59:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 531
|
||||
},
|
||||
{
|
||||
"name": "sngweizhi",
|
||||
"id": 47587454,
|
||||
"comment_id": 3713078490,
|
||||
"created_at": "2026-01-06T04:36:53Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 532
|
||||
},
|
||||
{
|
||||
"name": "ananas-viber",
|
||||
"id": 241022041,
|
||||
"comment_id": 3714661395,
|
||||
"created_at": "2026-01-06T13:16:18Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 544
|
||||
},
|
||||
{
|
||||
"name": "JohnC0de",
|
||||
"id": 88864312,
|
||||
"comment_id": 3714978210,
|
||||
"created_at": "2026-01-06T14:45:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 543
|
||||
},
|
||||
{
|
||||
"name": "atripathy86",
|
||||
"id": 3656621,
|
||||
"comment_id": 3715631259,
|
||||
"created_at": "2026-01-06T17:32:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 550
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -28,7 +28,6 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
const DEFAULT_MODEL = "opencode/glm-4.7-free"
|
||||
|
||||
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
@@ -25,7 +25,6 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -39,7 +38,7 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
Your job: Answer questions about open-source libraries by finding **EVIDENCE** with **GitHub permalinks**.
|
||||
Your job: Answer questions about open-source libraries. Provide **EVIDENCE** with **GitHub permalinks** when the question requires verification, implementation details, or current/version-specific information. For well-known APIs and stable concepts, answer directly from knowledge.
|
||||
|
||||
## CRITICAL: DATE AWARENESS
|
||||
|
||||
@@ -51,16 +50,20 @@ Your job: Answer questions about open-source libraries by finding **EVIDENCE** w
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0: REQUEST CLASSIFICATION (MANDATORY FIRST STEP)
|
||||
## PHASE 0: ASSESS BEFORE SEARCHING
|
||||
|
||||
Classify EVERY request into one of these categories before taking action:
|
||||
**First**: Can you answer confidently from training knowledge? If yes, answer directly.
|
||||
|
||||
**Search when**: version-specific info, implementation internals, recent changes, unfamiliar libraries, user explicitly requests source/examples.
|
||||
|
||||
**If search needed**, classify into:
|
||||
|
||||
| Type | Trigger Examples | Tools |
|
||||
|------|------------------|-------|
|
||||
| **TYPE A: CONCEPTUAL** | "How do I use X?", "Best practice for Y?" | context7 + websearch_exa (parallel) |
|
||||
| **TYPE A: CONCEPTUAL** | "How do I use X?", "Best practice for Y?" | context7 + web search (if available) in parallel |
|
||||
| **TYPE B: IMPLEMENTATION** | "How does X implement Y?", "Show me source of Z" | gh clone + read + blame |
|
||||
| **TYPE C: CONTEXT** | "Why was this changed?", "History of X?" | gh issues/prs + git log/blame |
|
||||
| **TYPE D: COMPREHENSIVE** | Complex/ambiguous requests | ALL tools in parallel |
|
||||
| **TYPE C: CONTEXT** | "Why was this changed?", "What's the history?", "Related issues/PRs?" | gh issues/prs + git log/blame |
|
||||
| **TYPE D: COMPREHENSIVE** | Complex/ambiguous requests | ALL available tools in parallel |
|
||||
|
||||
---
|
||||
|
||||
@@ -69,12 +72,12 @@ Classify EVERY request into one of these categories before taking action:
|
||||
### TYPE A: CONCEPTUAL QUESTION
|
||||
**Trigger**: "How do I...", "What is...", "Best practice for...", rough/general questions
|
||||
|
||||
**Execute in parallel (3+ calls)**:
|
||||
**If searching**, use tools as needed:
|
||||
\`\`\`
|
||||
Tool 1: context7_resolve-library-id("library-name")
|
||||
→ then context7_get-library-docs(id, topic: "specific-topic")
|
||||
Tool 2: websearch_exa_web_search_exa("library-name topic 2025")
|
||||
Tool 3: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
|
||||
Tool 2: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
|
||||
Tool 3 (optional): If web search is available, search "library-name topic 2025"
|
||||
\`\`\`
|
||||
|
||||
**Output**: Summarize findings with links to official docs and real-world examples.
|
||||
@@ -101,7 +104,7 @@ Step 4: Construct permalink
|
||||
https://github.com/owner/repo/blob/<sha>/path/to/file#L10-L20
|
||||
\`\`\`
|
||||
|
||||
**Parallel acceleration (4+ calls)**:
|
||||
**For faster results, parallelize**:
|
||||
\`\`\`
|
||||
Tool 1: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
|
||||
Tool 2: grep_app_searchGitHub(query: "function_name", repo: "owner/repo")
|
||||
@@ -114,7 +117,7 @@ Tool 4: context7_get-library-docs(id, topic: "relevant-api")
|
||||
### TYPE C: CONTEXT & HISTORY
|
||||
**Trigger**: "Why was this changed?", "What's the history?", "Related issues/PRs?"
|
||||
|
||||
**Execute in parallel (4+ calls)**:
|
||||
**Tools to use**:
|
||||
\`\`\`
|
||||
Tool 1: gh search issues "keyword" --repo owner/repo --state all --limit 10
|
||||
Tool 2: gh search prs "keyword" --repo owner/repo --state merged --limit 10
|
||||
@@ -136,21 +139,22 @@ gh api repos/owner/repo/pulls/<number>/files
|
||||
### TYPE D: COMPREHENSIVE RESEARCH
|
||||
**Trigger**: Complex questions, ambiguous requests, "deep dive into..."
|
||||
|
||||
**Execute ALL in parallel (6+ calls)**:
|
||||
**Use multiple tools as needed**:
|
||||
\`\`\`
|
||||
// Documentation & Web
|
||||
// Documentation
|
||||
Tool 1: context7_resolve-library-id → context7_get-library-docs
|
||||
Tool 2: websearch_exa_web_search_exa("topic recent updates")
|
||||
|
||||
// Code Search
|
||||
Tool 3: grep_app_searchGitHub(query: "pattern1", language: [...])
|
||||
Tool 4: grep_app_searchGitHub(query: "pattern2", useRegexp: true)
|
||||
Tool 2: grep_app_searchGitHub(query: "pattern1", language: [...])
|
||||
Tool 3: grep_app_searchGitHub(query: "pattern2", useRegexp: true)
|
||||
|
||||
// Source Analysis
|
||||
Tool 5: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
|
||||
Tool 4: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
|
||||
|
||||
// Context
|
||||
Tool 6: gh search issues "topic" --repo owner/repo
|
||||
Tool 5: gh search issues "topic" --repo owner/repo
|
||||
|
||||
// Optional: If web search is available, search for recent updates
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -196,7 +200,6 @@ https://github.com/tanstack/query/blob/abc123def/packages/react-query/src/useQue
|
||||
| Purpose | Tool | Command/Usage |
|
||||
|---------|------|---------------|
|
||||
| **Official Docs** | context7 | \`context7_resolve-library-id\` → \`context7_get-library-docs\` |
|
||||
| **Latest Info** | websearch_exa | \`websearch_exa_web_search_exa("query 2025")\` |
|
||||
| **Fast Code Search** | grep_app | \`grep_app_searchGitHub(query, language, useRegexp)\` |
|
||||
| **Deep Code Search** | gh CLI | \`gh search code "query" --repo owner/repo\` |
|
||||
| **Clone Repo** | gh CLI | \`gh repo clone owner/repo \${TMPDIR:-/tmp}/name -- --depth 1\` |
|
||||
@@ -205,6 +208,7 @@ https://github.com/tanstack/query/blob/abc123def/packages/react-query/src/useQue
|
||||
| **Release Info** | gh CLI | \`gh api repos/owner/repo/releases/latest\` |
|
||||
| **Git History** | git | \`git log\`, \`git blame\`, \`git show\` |
|
||||
| **Read URL** | webfetch | \`webfetch(url)\` for blog posts, SO threads |
|
||||
| **Web Search** | (if available) | Use any available web search tool for latest info |
|
||||
|
||||
### Temp Directory
|
||||
|
||||
@@ -221,14 +225,16 @@ Use OS-appropriate temp directory:
|
||||
|
||||
---
|
||||
|
||||
## PARALLEL EXECUTION REQUIREMENTS
|
||||
## PARALLEL EXECUTION GUIDANCE
|
||||
|
||||
| Request Type | Minimum Parallel Calls |
|
||||
|--------------|----------------------|
|
||||
| TYPE A (Conceptual) | 3+ |
|
||||
| TYPE B (Implementation) | 4+ |
|
||||
| TYPE C (Context) | 4+ |
|
||||
| TYPE D (Comprehensive) | 6+ |
|
||||
When searching is needed, scale effort to question complexity:
|
||||
|
||||
| Request Type | Suggested Calls |
|
||||
|--------------|----------------|
|
||||
| TYPE A (Conceptual) | 1-2 |
|
||||
| TYPE B (Implementation) | 2-3 |
|
||||
| TYPE C (Context) | 2-3 |
|
||||
| TYPE D (Comprehensive) | 3-5 |
|
||||
|
||||
**Always vary queries** when using grep_app:
|
||||
\`\`\`
|
||||
|
||||
@@ -18,7 +18,6 @@ export function createMultimodalLookerAgent(
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -102,7 +102,6 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
"write",
|
||||
"edit",
|
||||
"task",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
const base = {
|
||||
|
||||
@@ -11,11 +11,9 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
|
||||
expect(models).toBeTruthy()
|
||||
|
||||
const required = [
|
||||
"gemini-3-pro-high",
|
||||
"gemini-3-pro-medium",
|
||||
"gemini-3-pro-low",
|
||||
"gemini-3-flash",
|
||||
"gemini-3-flash-lite",
|
||||
"antigravity-gemini-3-pro-high",
|
||||
"antigravity-gemini-3-pro-low",
|
||||
"antigravity-gemini-3-flash",
|
||||
]
|
||||
|
||||
for (const key of required) {
|
||||
|
||||
@@ -55,8 +55,6 @@ function getOmoConfig(): string {
|
||||
return getConfigContext().paths.omoConfig
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -276,31 +274,33 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
|
||||
const agents: Record<string, Record<string, unknown>> = {}
|
||||
|
||||
if (!installConfig.hasClaude) {
|
||||
agents["Sisyphus"] = { model: "opencode/big-pickle" }
|
||||
agents["Sisyphus"] = { model: "opencode/glm-4.7-free" }
|
||||
}
|
||||
|
||||
agents["librarian"] = { model: "opencode/glm-4.7-free" }
|
||||
|
||||
// Gemini models use `antigravity-` prefix for explicit Antigravity quota routing
|
||||
// @see ANTIGRAVITY_PROVIDER_CONFIG comments for rationale
|
||||
if (installConfig.hasGemini) {
|
||||
agents["librarian"] = { model: "google/gemini-3-flash" }
|
||||
agents["explore"] = { model: "google/gemini-3-flash" }
|
||||
agents["explore"] = { model: "google/antigravity-gemini-3-flash" }
|
||||
} else if (installConfig.hasClaude && installConfig.isMax20) {
|
||||
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
|
||||
} else {
|
||||
agents["librarian"] = { model: "opencode/big-pickle" }
|
||||
agents["explore"] = { model: "opencode/big-pickle" }
|
||||
agents["explore"] = { model: "opencode/glm-4.7-free" }
|
||||
}
|
||||
|
||||
if (!installConfig.hasChatGPT) {
|
||||
agents["oracle"] = {
|
||||
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle",
|
||||
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free",
|
||||
}
|
||||
}
|
||||
|
||||
if (installConfig.hasGemini) {
|
||||
agents["frontend-ui-ux-engineer"] = { model: "google/gemini-3-pro-high" }
|
||||
agents["document-writer"] = { model: "google/gemini-3-flash" }
|
||||
agents["multimodal-looker"] = { model: "google/gemini-3-flash" }
|
||||
agents["frontend-ui-ux-engineer"] = { model: "google/antigravity-gemini-3-pro-high" }
|
||||
agents["document-writer"] = { model: "google/antigravity-gemini-3-flash" }
|
||||
agents["multimodal-looker"] = { model: "google/antigravity-gemini-3-flash" }
|
||||
} else {
|
||||
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle"
|
||||
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free"
|
||||
agents["frontend-ui-ux-engineer"] = { model: fallbackModel }
|
||||
agents["document-writer"] = { model: fallbackModel }
|
||||
agents["multimodal-looker"] = { model: fallbackModel }
|
||||
@@ -441,48 +441,6 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
}
|
||||
}
|
||||
|
||||
export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const packageJsonPath = getPackageJson()
|
||||
|
||||
try {
|
||||
let packageJson: Record<string, unknown> = {}
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const stat = statSync(packageJsonPath)
|
||||
const content = readFileSync(packageJsonPath, "utf-8")
|
||||
|
||||
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
|
||||
packageJson = JSON.parse(content)
|
||||
if (typeof packageJson !== "object" || packageJson === null || Array.isArray(packageJson)) {
|
||||
packageJson = {}
|
||||
}
|
||||
}
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
packageJson = {}
|
||||
} else {
|
||||
throw parseErr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
|
||||
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
|
||||
packageJson.dependencies = deps
|
||||
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: packageJsonPath }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: packageJsonPath, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
}
|
||||
}
|
||||
|
||||
export interface BunInstallResult {
|
||||
success: boolean
|
||||
timedOut?: boolean
|
||||
@@ -541,45 +499,44 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Antigravity Provider Configuration
|
||||
*
|
||||
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
|
||||
*
|
||||
* The opencode-antigravity-auth plugin supports two naming conventions:
|
||||
* - `antigravity-gemini-3-pro-high` (RECOMMENDED, explicit Antigravity quota routing)
|
||||
* - `gemini-3-pro-high` (LEGACY, backward compatible but may break in future)
|
||||
*
|
||||
* Legacy names rely on Gemini CLI using `-preview` suffix for disambiguation.
|
||||
* If Google removes `-preview`, legacy names may route to wrong quota.
|
||||
*
|
||||
* @see https://github.com/NoeFabris/opencode-antigravity-auth#migration-guide-v127
|
||||
*/
|
||||
export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
||||
google: {
|
||||
name: "Google",
|
||||
// NOTE: opencode-antigravity-auth expects full model specs (name/limit/modalities).
|
||||
// If these are incomplete, models may appear but fail at runtime (e.g. 404).
|
||||
models: {
|
||||
"gemini-3-pro-high": {
|
||||
"antigravity-gemini-3-pro-high": {
|
||||
name: "Gemini 3 Pro High (Antigravity)",
|
||||
thinking: true,
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-pro-medium": {
|
||||
name: "Gemini 3 Pro Medium (Antigravity)",
|
||||
thinking: true,
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-pro-low": {
|
||||
"antigravity-gemini-3-pro-low": {
|
||||
name: "Gemini 3 Pro Low (Antigravity)",
|
||||
thinking: true,
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65535 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-flash": {
|
||||
"antigravity-gemini-3-flash": {
|
||||
name: "Gemini 3 Flash (Antigravity)",
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65536 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
"gemini-3-flash-lite": {
|
||||
name: "Gemini 3 Flash Lite (Antigravity)",
|
||||
attachment: true,
|
||||
limit: { context: 1048576, output: 65536 },
|
||||
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -587,12 +544,48 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
|
||||
const CODEX_PROVIDER_CONFIG = {
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
api: "codex",
|
||||
options: {
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
textVerbosity: "medium",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
models: {
|
||||
"gpt-5.2": { name: "GPT-5.2" },
|
||||
"o3": { name: "o3", thinking: true },
|
||||
"o4-mini": { name: "o4-mini", thinking: true },
|
||||
"codex-1": { name: "Codex-1" },
|
||||
"gpt-5.2": {
|
||||
name: "GPT 5.2 (OAuth)",
|
||||
limit: { context: 272000, output: 128000 },
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
none: { reasoningEffort: "none", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
},
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
name: "GPT 5.2 Codex (OAuth)",
|
||||
limit: { context: 272000, output: 128000 },
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
|
||||
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
},
|
||||
},
|
||||
"gpt-5.1-codex-max": {
|
||||
name: "GPT 5.1 Codex Max (OAuth)",
|
||||
limit: { context: 272000, output: 128000 },
|
||||
modalities: { input: ["text", "image"], output: ["text"] },
|
||||
variants: {
|
||||
low: { reasoningEffort: "low", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
medium: { reasoningEffort: "medium", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -698,17 +691,17 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
|
||||
const agents = omoConfig.agents ?? {}
|
||||
|
||||
if (agents["Sisyphus"]?.model === "opencode/big-pickle") {
|
||||
if (agents["Sisyphus"]?.model === "opencode/glm-4.7-free") {
|
||||
result.hasClaude = false
|
||||
result.isMax20 = false
|
||||
} else if (agents["librarian"]?.model === "opencode/big-pickle") {
|
||||
} else if (agents["librarian"]?.model === "opencode/glm-4.7-free") {
|
||||
result.hasClaude = true
|
||||
result.isMax20 = false
|
||||
}
|
||||
|
||||
if (agents["oracle"]?.model?.startsWith("anthropic/")) {
|
||||
result.hasChatGPT = false
|
||||
} else if (agents["oracle"]?.model === "opencode/big-pickle") {
|
||||
} else if (agents["oracle"]?.model === "opencode/glm-4.7-free") {
|
||||
result.hasChatGPT = false
|
||||
}
|
||||
|
||||
|
||||
@@ -9,11 +9,10 @@ describe("mcp check", () => {
|
||||
const servers = mcp.getBuiltinMcpInfo()
|
||||
|
||||
// #then should include expected servers
|
||||
expect(servers.length).toBe(3)
|
||||
expect(servers.length).toBe(2)
|
||||
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")
|
||||
})
|
||||
})
|
||||
@@ -37,7 +36,7 @@ describe("mcp check", () => {
|
||||
|
||||
// #then should pass
|
||||
expect(result.status).toBe("pass")
|
||||
expect(result.message).toContain("3")
|
||||
expect(result.message).toContain("2")
|
||||
expect(result.message).toContain("enabled")
|
||||
})
|
||||
|
||||
@@ -48,7 +47,6 @@ describe("mcp check", () => {
|
||||
|
||||
// #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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
|
||||
|
||||
const MCP_CONFIG_PATHS = [
|
||||
join(homedir(), ".claude", ".mcp.json"),
|
||||
|
||||
@@ -30,7 +30,7 @@ describe("runner", () => {
|
||||
name: "Test Check",
|
||||
category: "installation",
|
||||
check: async () => {
|
||||
await new Promise((r) => setTimeout(r, 10))
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
return { name: "Test", status: "pass", message: "OK" }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
isOpenCodeInstalled,
|
||||
getOpenCodeVersion,
|
||||
addAuthPlugins,
|
||||
setupChatGPTHotfix,
|
||||
runBunInstall,
|
||||
addProviderConfig,
|
||||
detectCurrentConfig,
|
||||
} from "./config-manager"
|
||||
@@ -48,10 +46,10 @@ function formatConfigSummary(config: InstallConfig): string {
|
||||
lines.push(color.bold(color.white("Agent Configuration")))
|
||||
lines.push("")
|
||||
|
||||
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "big-pickle"
|
||||
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
|
||||
const librarianModel = config.hasClaude && config.isMax20 ? "claude-sonnet-4-5" : "big-pickle"
|
||||
const frontendModel = config.hasGemini ? "gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
|
||||
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free"
|
||||
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
|
||||
const librarianModel = "glm-4.7-free"
|
||||
const frontendModel = config.hasGemini ? "antigravity-gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
|
||||
|
||||
lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`)
|
||||
lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`)
|
||||
@@ -163,7 +161,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
|
||||
const claude = await p.select({
|
||||
message: "Do you have a Claude Pro/Max subscription?",
|
||||
options: [
|
||||
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
|
||||
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
|
||||
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
|
||||
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
|
||||
],
|
||||
@@ -279,26 +277,6 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
step += 2
|
||||
}
|
||||
|
||||
if (config.hasChatGPT) {
|
||||
printStep(step++, totalSteps, "Setting up ChatGPT hotfix...")
|
||||
const hotfixResult = setupChatGPTHotfix()
|
||||
if (!hotfixResult.success) {
|
||||
printError(`Failed: ${hotfixResult.error}`)
|
||||
return 1
|
||||
}
|
||||
printSuccess(`Hotfix configured ${SYMBOLS.arrow} ${color.dim(hotfixResult.configPath)}`)
|
||||
|
||||
printInfo("Installing dependencies with bun...")
|
||||
const bunSuccess = await runBunInstall()
|
||||
if (bunSuccess) {
|
||||
printSuccess("Dependencies installed")
|
||||
} else {
|
||||
printWarning("bun install failed - run manually: cd ~/.config/opencode && bun i")
|
||||
}
|
||||
} else {
|
||||
step++
|
||||
}
|
||||
|
||||
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
|
||||
const omoResult = writeOmoConfig(config)
|
||||
if (!omoResult.success) {
|
||||
@@ -310,7 +288,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
|
||||
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
||||
|
||||
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
|
||||
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
|
||||
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
|
||||
}
|
||||
|
||||
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
|
||||
@@ -410,25 +388,6 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
|
||||
}
|
||||
|
||||
if (config.hasChatGPT) {
|
||||
s.start("Setting up ChatGPT hotfix")
|
||||
const hotfixResult = setupChatGPTHotfix()
|
||||
if (!hotfixResult.success) {
|
||||
s.stop(`Failed to setup hotfix: ${hotfixResult.error}`)
|
||||
p.outro(color.red("Installation failed."))
|
||||
return 1
|
||||
}
|
||||
s.stop(`Hotfix configured in ${color.cyan(hotfixResult.configPath)}`)
|
||||
|
||||
s.start("Installing dependencies with bun")
|
||||
const bunSuccess = await runBunInstall()
|
||||
if (bunSuccess) {
|
||||
s.stop("Dependencies installed")
|
||||
} else {
|
||||
s.stop(color.yellow("bun install failed - run manually: cd ~/.config/opencode && bun i"))
|
||||
}
|
||||
}
|
||||
|
||||
s.start("Writing oh-my-opencode configuration")
|
||||
const omoResult = writeOmoConfig(config)
|
||||
if (!omoResult.success) {
|
||||
@@ -439,7 +398,7 @@ export async function install(args: InstallArgs): Promise<number> {
|
||||
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
|
||||
|
||||
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
|
||||
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
|
||||
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
|
||||
}
|
||||
|
||||
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
|
||||
|
||||
136
src/config/schema.test.ts
Normal file
136
src/config/schema.test.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { OhMyOpenCodeConfigSchema } from "./schema"
|
||||
|
||||
describe("disabled_mcps schema", () => {
|
||||
test("should accept built-in MCP names", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: ["context7", "grep_app"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.disabled_mcps).toEqual(["context7", "grep_app"])
|
||||
}
|
||||
})
|
||||
|
||||
test("should accept custom MCP names", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: ["playwright", "sqlite", "custom-mcp"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.disabled_mcps).toEqual(["playwright", "sqlite", "custom-mcp"])
|
||||
}
|
||||
})
|
||||
|
||||
test("should accept mixed built-in and custom names", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: ["context7", "playwright", "custom-server"],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.disabled_mcps).toEqual(["context7", "playwright", "custom-server"])
|
||||
}
|
||||
})
|
||||
|
||||
test("should accept empty array", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: [],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.disabled_mcps).toEqual([])
|
||||
}
|
||||
})
|
||||
|
||||
test("should reject non-string values", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: [123, true, null],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("should accept undefined (optional field)", () => {
|
||||
//#given
|
||||
const config = {}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.disabled_mcps).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
test("should reject empty strings", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: [""],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
|
||||
test("should accept MCP names with various naming patterns", () => {
|
||||
//#given
|
||||
const config = {
|
||||
disabled_mcps: [
|
||||
"my-custom-mcp",
|
||||
"my_custom_mcp",
|
||||
"myCustomMcp",
|
||||
"my.custom.mcp",
|
||||
"my-custom-mcp-123",
|
||||
],
|
||||
}
|
||||
|
||||
//#when
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(config)
|
||||
|
||||
//#then
|
||||
expect(result.success).toBe(true)
|
||||
if (result.success) {
|
||||
expect(result.data.disabled_mcps).toEqual([
|
||||
"my-custom-mcp",
|
||||
"my_custom_mcp",
|
||||
"myCustomMcp",
|
||||
"my.custom.mcp",
|
||||
"my-custom-mcp-123",
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { z } from "zod"
|
||||
import { McpNameSchema } from "../mcp/types"
|
||||
import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types"
|
||||
|
||||
const PermissionValue = z.enum(["ask", "allow", "deny"])
|
||||
|
||||
@@ -232,9 +232,15 @@ export const RalphLoopConfigSchema = z.object({
|
||||
state_dir: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(1)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(McpNameSchema).optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||
disabled_hooks: z.array(HookNameSchema).optional(),
|
||||
@@ -248,11 +254,13 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
auto_update: z.boolean().optional(),
|
||||
skills: SkillsConfigSchema.optional(),
|
||||
ralph_loop: RalphLoopConfigSchema.optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
@@ -265,4 +273,4 @@ 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"
|
||||
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
|
||||
|
||||
351
src/features/background-agent/concurrency.test.ts
Normal file
351
src/features/background-agent/concurrency.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
test("should return model-specific limit when modelConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 5 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return provider limit when providerConcurrency is set for model provider", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return default limit when defaultConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(2)
|
||||
})
|
||||
|
||||
test("should return default 5 when no config provided", () => {
|
||||
// #given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return default 5 when config exists but no concurrency settings", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should prioritize model-specific over provider-specific over default", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 10 },
|
||||
providerConcurrency: { anthropic: 5 },
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
|
||||
// #then
|
||||
expect(modelLimit).toBe(10)
|
||||
expect(providerLimit).toBe(5)
|
||||
expect(defaultLimit).toBe(2)
|
||||
})
|
||||
|
||||
test("should handle models without provider part", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { "custom-model": 4 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("custom-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(4)
|
||||
})
|
||||
|
||||
test("should return Infinity when defaultConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 0 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("any-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when providerConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when modelConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConcurrencyManager.acquire/release", () => {
|
||||
let manager: ConcurrencyManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
manager = new ConcurrencyManager(config)
|
||||
})
|
||||
|
||||
test("should allow acquiring up to limit", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should allow acquires up to default limit of 5", async () => {
|
||||
// #given - no config = default limit of 5
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - all 5 resolved
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should still be waiting
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// #when - release
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
|
||||
// #then - now resolved
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue multiple tasks and process in order", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
const order: string[] = []
|
||||
const task1 = manager.acquire("model-a").then(() => { order.push("1") })
|
||||
const task2 = manager.acquire("model-a").then(() => { order.push("2") })
|
||||
const task3 = manager.acquire("model-a").then(() => { order.push("3") })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - none resolved yet
|
||||
expect(order).toEqual([])
|
||||
|
||||
// #when - release one at a time
|
||||
manager.release("model-a")
|
||||
await task1
|
||||
expect(order).toEqual(["1"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task2
|
||||
expect(order).toEqual(["1", "2"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task3
|
||||
expect(order).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("should handle independent models separately", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when - acquire different model
|
||||
const resolved = await Promise.race([
|
||||
manager.acquire("model-b").then(() => "resolved"),
|
||||
Promise.resolve("timeout").then(() => "timeout")
|
||||
])
|
||||
|
||||
// #then - different model should resolve immediately
|
||||
expect(resolved).toBe("resolved")
|
||||
})
|
||||
|
||||
test("should allow re-acquiring after release", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// Release all
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 2 },
|
||||
defaultConcurrency: 5
|
||||
}
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("anthropic/claude-sonnet-4-5").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should be waiting (model-specific limit is 2)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// Cleanup
|
||||
manager.release("anthropic/claude-sonnet-4-5")
|
||||
await waitPromise
|
||||
})
|
||||
})
|
||||
66
src/features/background-agent/concurrency.ts
Normal file
66
src/features/background-agent/concurrency.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
export class ConcurrencyManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private counts: Map<string, number> = new Map()
|
||||
private queues: Map<string, Array<() => void>> = new Map()
|
||||
|
||||
constructor(config?: BackgroundTaskConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
getConcurrencyLimit(model: string): number {
|
||||
const modelLimit = this.config?.modelConcurrency?.[model]
|
||||
if (modelLimit !== undefined) {
|
||||
return modelLimit === 0 ? Infinity : modelLimit
|
||||
}
|
||||
const provider = model.split('/')[0]
|
||||
const providerLimit = this.config?.providerConcurrency?.[provider]
|
||||
if (providerLimit !== undefined) {
|
||||
return providerLimit === 0 ? Infinity : providerLimit
|
||||
}
|
||||
const defaultLimit = this.config?.defaultConcurrency
|
||||
if (defaultLimit !== undefined) {
|
||||
return defaultLimit === 0 ? Infinity : defaultLimit
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
async acquire(model: string): Promise<void> {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current < limit) {
|
||||
this.counts.set(model, current + 1)
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const queue = this.queues.get(model) ?? []
|
||||
queue.push(resolve)
|
||||
this.queues.set(model, queue)
|
||||
})
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const queue = this.queues.get(model)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
this.counts.set(model, this.counts.get(model) ?? 0)
|
||||
next()
|
||||
} else {
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current > 0) {
|
||||
this.counts.set(model, current - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -302,6 +302,74 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - release ordering", () => {
|
||||
test("should unblock queued task even when prompt hangs", async () => {
|
||||
// #given - concurrency limit 1, task1 running, task2 waiting
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
await concurrencyManager.acquire("explore")
|
||||
|
||||
let task2Resolved = false
|
||||
const task2Promise = concurrencyManager.acquire("explore").then(() => {
|
||||
task2Resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(false)
|
||||
|
||||
// #when - simulate notifyParentSession: release BEFORE prompt (fixed behavior)
|
||||
let promptStarted = false
|
||||
const simulateNotifyParentSession = async () => {
|
||||
concurrencyManager.release("explore")
|
||||
|
||||
promptStarted = true
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
simulateNotifyParentSession()
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - task2 should be unblocked even though prompt never completes
|
||||
expect(promptStarted).toBe(true)
|
||||
await task2Promise
|
||||
expect(task2Resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should keep queue blocked if release is after prompt (demonstrates the bug)", async () => {
|
||||
// #given - same setup
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
await concurrencyManager.acquire("explore")
|
||||
|
||||
let task2Resolved = false
|
||||
concurrencyManager.acquire("explore").then(() => {
|
||||
task2Resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(false)
|
||||
|
||||
// #when - simulate BUGGY behavior: release AFTER prompt (in finally)
|
||||
const simulateBuggyNotifyParentSession = async () => {
|
||||
try {
|
||||
await new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 50))
|
||||
} finally {
|
||||
concurrencyManager.release("explore")
|
||||
}
|
||||
}
|
||||
|
||||
await simulateBuggyNotifyParentSession().catch(() => {})
|
||||
|
||||
// #then - task2 resolves only after prompt completes (blocked during hang)
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
|
||||
let manager: MockBackgroundManager
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
LaunchInput,
|
||||
} from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
@@ -60,12 +62,14 @@ export class BackgroundManager {
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: ReturnType<typeof setInterval>
|
||||
private concurrencyManager: ConcurrencyManager
|
||||
|
||||
constructor(ctx: PluginInput) {
|
||||
constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
this.concurrencyManager = new ConcurrencyManager(config)
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
@@ -73,14 +77,22 @@ export class BackgroundManager {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
const model = input.agent
|
||||
|
||||
await this.concurrencyManager.acquire(model)
|
||||
|
||||
const createResult = await this.client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
}).catch((error) => {
|
||||
this.concurrencyManager.release(model)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
this.concurrencyManager.release(model)
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
@@ -102,6 +114,7 @@ export class BackgroundManager {
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentModel: input.parentModel,
|
||||
model,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -116,6 +129,7 @@ export class BackgroundManager {
|
||||
tools: {
|
||||
task: false,
|
||||
background_task: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
@@ -131,6 +145,9 @@ export class BackgroundManager {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.model) {
|
||||
this.concurrencyManager.release(existingTask.model)
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask)
|
||||
}
|
||||
@@ -252,6 +269,9 @@ export class BackgroundManager {
|
||||
task.error = "Session deleted"
|
||||
}
|
||||
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
subagentSessions.delete(sessionID)
|
||||
@@ -329,6 +349,10 @@ export class BackgroundManager {
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(async () => {
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
@@ -351,7 +375,6 @@ export class BackgroundManager {
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
// Always clean up both maps to prevent memory leaks
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
@@ -390,6 +413,9 @@ export class BackgroundManager {
|
||||
task.status = "error"
|
||||
task.error = "Task timed out after 30 minutes"
|
||||
task.completedAt = new Date()
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface BackgroundTask {
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
import { REFACTOR_TEMPLATE } from "./templates/refactor"
|
||||
|
||||
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
|
||||
"init-deep": {
|
||||
@@ -32,6 +33,14 @@ $ARGUMENTS
|
||||
${CANCEL_RALPH_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
},
|
||||
refactor: {
|
||||
description:
|
||||
"(builtin) Intelligent refactoring command with LSP, AST-grep, architecture analysis, codemap, and TDD verification.",
|
||||
template: `<command-instruction>
|
||||
${REFACTOR_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
argumentHint: "<refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]",
|
||||
},
|
||||
}
|
||||
|
||||
export function loadBuiltinCommands(
|
||||
|
||||
624
src/features/builtin-commands/templates/refactor.ts
Normal file
624
src/features/builtin-commands/templates/refactor.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
export const REFACTOR_TEMPLATE = `# Intelligent Refactor Command
|
||||
|
||||
## Usage
|
||||
\`\`\`
|
||||
/refactor <refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]
|
||||
|
||||
Arguments:
|
||||
refactoring-target: What to refactor. Can be:
|
||||
- File path: src/auth/handler.ts
|
||||
- Symbol name: "AuthService class"
|
||||
- Pattern: "all functions using deprecated API"
|
||||
- Description: "extract validation logic into separate module"
|
||||
|
||||
Options:
|
||||
--scope: Refactoring scope (default: module)
|
||||
- file: Single file only
|
||||
- module: Module/directory scope
|
||||
- project: Entire codebase
|
||||
|
||||
--strategy: Risk tolerance (default: safe)
|
||||
- safe: Conservative, maximum test coverage required
|
||||
- aggressive: Allow broader changes with adequate coverage
|
||||
\`\`\`
|
||||
|
||||
## What This Command Does
|
||||
|
||||
Performs intelligent, deterministic refactoring with full codebase awareness. Unlike blind search-and-replace, this command:
|
||||
|
||||
1. **Understands your intent** - Analyzes what you actually want to achieve
|
||||
2. **Maps the codebase** - Builds a definitive codemap before touching anything
|
||||
3. **Assesses risk** - Evaluates test coverage and determines verification strategy
|
||||
4. **Plans meticulously** - Creates a detailed plan with Plan agent
|
||||
5. **Executes precisely** - Step-by-step refactoring with LSP and AST-grep
|
||||
6. **Verifies constantly** - Runs tests after each change to ensure zero regression
|
||||
|
||||
---
|
||||
|
||||
# PHASE 0: INTENT GATE (MANDATORY FIRST STEP)
|
||||
|
||||
**BEFORE ANY ACTION, classify and validate the request.**
|
||||
|
||||
## Step 0.1: Parse Request Type
|
||||
|
||||
| Signal | Classification | Action |
|
||||
|--------|----------------|--------|
|
||||
| Specific file/symbol | Explicit | Proceed to codebase analysis |
|
||||
| "Refactor X to Y" | Clear transformation | Proceed to codebase analysis |
|
||||
| "Improve", "Clean up" | Open-ended | **MUST ask**: "What specific improvement?" |
|
||||
| Ambiguous scope | Uncertain | **MUST ask**: "Which modules/files?" |
|
||||
| Missing context | Incomplete | **MUST ask**: "What's the desired outcome?" |
|
||||
|
||||
## Step 0.2: Validate Understanding
|
||||
|
||||
Before proceeding, confirm:
|
||||
- [ ] Target is clearly identified
|
||||
- [ ] Desired outcome is understood
|
||||
- [ ] Scope is defined (file/module/project)
|
||||
- [ ] Success criteria can be articulated
|
||||
|
||||
**If ANY of above is unclear, ASK CLARIFYING QUESTION:**
|
||||
|
||||
\`\`\`
|
||||
I want to make sure I understand the refactoring goal correctly.
|
||||
|
||||
**What I understood**: [interpretation]
|
||||
**What I'm unsure about**: [specific ambiguity]
|
||||
|
||||
Options I see:
|
||||
1. [Option A] - [implications]
|
||||
2. [Option B] - [implications]
|
||||
|
||||
**My recommendation**: [suggestion with reasoning]
|
||||
|
||||
Should I proceed with [recommendation], or would you prefer differently?
|
||||
\`\`\`
|
||||
|
||||
## Step 0.3: Create Initial Todos
|
||||
|
||||
**IMMEDIATELY after understanding the request, create todos:**
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
{"id": "phase-1", "content": "PHASE 1: Codebase Analysis - launch parallel explore agents", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-2", "content": "PHASE 2: Build Codemap - map dependencies and impact zones", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-3", "content": "PHASE 3: Test Assessment - analyze test coverage and verification strategy", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-4", "content": "PHASE 4: Plan Generation - invoke Plan agent for detailed refactoring plan", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-5", "content": "PHASE 5: Execute Refactoring - step-by-step with continuous verification", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-6", "content": "PHASE 6: Final Verification - full test suite and regression check", "status": "pending", "priority": "high"}
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# PHASE 1: CODEBASE ANALYSIS (PARALLEL EXPLORATION)
|
||||
|
||||
**Mark phase-1 as in_progress.**
|
||||
|
||||
## 1.1: Launch Parallel Explore Agents (BACKGROUND)
|
||||
|
||||
Fire ALL of these simultaneously using \`call_omo_agent\`:
|
||||
|
||||
\`\`\`
|
||||
// Agent 1: Find the refactoring target
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all occurrences and definitions of [TARGET].
|
||||
Report: file paths, line numbers, usage patterns."
|
||||
)
|
||||
|
||||
// Agent 2: Find related code
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all code that imports, uses, or depends on [TARGET].
|
||||
Report: dependency chains, import graphs."
|
||||
)
|
||||
|
||||
// Agent 3: Find similar patterns
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find similar code patterns to [TARGET] in the codebase.
|
||||
Report: analogous implementations, established conventions."
|
||||
)
|
||||
|
||||
// Agent 4: Find tests
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all test files related to [TARGET].
|
||||
Report: test file paths, test case names, coverage indicators."
|
||||
)
|
||||
|
||||
// Agent 5: Architecture context
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find architectural patterns and module organization around [TARGET].
|
||||
Report: module boundaries, layer structure, design patterns in use."
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 1.2: Direct Tool Exploration (WHILE AGENTS RUN)
|
||||
|
||||
While background agents are running, use direct tools:
|
||||
|
||||
### LSP Tools for Precise Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Get symbol information at target location
|
||||
lsp_hover(filePath, line, character) // Type info, docs, signatures
|
||||
|
||||
// Find definition(s)
|
||||
lsp_goto_definition(filePath, line, character) // Where is it defined?
|
||||
|
||||
// Find ALL usages across workspace
|
||||
lsp_find_references(filePath, line, character, includeDeclaration=true)
|
||||
|
||||
// Get file structure
|
||||
lsp_document_symbols(filePath) // Hierarchical outline
|
||||
|
||||
// Search symbols by name
|
||||
lsp_workspace_symbols(filePath, query="[target_symbol]")
|
||||
|
||||
// Get current diagnostics
|
||||
lsp_diagnostics(filePath) // Errors, warnings before we start
|
||||
\`\`\`
|
||||
|
||||
### AST-Grep for Pattern Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Find structural patterns
|
||||
ast_grep_search(
|
||||
pattern="function $NAME($$$) { $$$ }", // or relevant pattern
|
||||
lang="typescript", // or relevant language
|
||||
paths=["src/"]
|
||||
)
|
||||
|
||||
// Preview refactoring (DRY RUN)
|
||||
ast_grep_replace(
|
||||
pattern="[old_pattern]",
|
||||
rewrite="[new_pattern]",
|
||||
lang="[language]",
|
||||
dryRun=true // ALWAYS preview first
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Grep for Text Patterns:
|
||||
|
||||
\`\`\`
|
||||
grep(pattern="[search_term]", path="src/", include="*.ts")
|
||||
\`\`\`
|
||||
|
||||
## 1.3: Collect Background Results
|
||||
|
||||
\`\`\`
|
||||
background_output(task_id="[agent_1_id]")
|
||||
background_output(task_id="[agent_2_id]")
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-1 as completed after all results collected.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 2: BUILD CODEMAP (DEPENDENCY MAPPING)
|
||||
|
||||
**Mark phase-2 as in_progress.**
|
||||
|
||||
## 2.1: Construct Definitive Codemap
|
||||
|
||||
Based on Phase 1 results, build:
|
||||
|
||||
\`\`\`
|
||||
## CODEMAP: [TARGET]
|
||||
|
||||
### Core Files (Direct Impact)
|
||||
- \`path/to/file.ts:L10-L50\` - Primary definition
|
||||
- \`path/to/file2.ts:L25\` - Key usage
|
||||
|
||||
### Dependency Graph
|
||||
\`\`\`
|
||||
[TARGET]
|
||||
├── imports from:
|
||||
│ ├── module-a (types)
|
||||
│ └── module-b (utils)
|
||||
├── imported by:
|
||||
│ ├── consumer-1.ts
|
||||
│ ├── consumer-2.ts
|
||||
│ └── consumer-3.ts
|
||||
└── used by:
|
||||
├── handler.ts (direct call)
|
||||
└── service.ts (dependency injection)
|
||||
\`\`\`
|
||||
|
||||
### Impact Zones
|
||||
| Zone | Risk Level | Files Affected | Test Coverage |
|
||||
|------|------------|----------------|---------------|
|
||||
| Core | HIGH | 3 files | 85% covered |
|
||||
| Consumers | MEDIUM | 8 files | 70% covered |
|
||||
| Edge | LOW | 2 files | 50% covered |
|
||||
|
||||
### Established Patterns
|
||||
- Pattern A: [description] - used in N places
|
||||
- Pattern B: [description] - established convention
|
||||
\`\`\`
|
||||
|
||||
## 2.2: Identify Refactoring Constraints
|
||||
|
||||
Based on codemap:
|
||||
- **MUST follow**: [existing patterns identified]
|
||||
- **MUST NOT break**: [critical dependencies]
|
||||
- **Safe to change**: [isolated code zones]
|
||||
- **Requires migration**: [breaking changes impact]
|
||||
|
||||
**Mark phase-2 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 3: TEST ASSESSMENT (VERIFICATION STRATEGY)
|
||||
|
||||
**Mark phase-3 as in_progress.**
|
||||
|
||||
## 3.1: Detect Test Infrastructure
|
||||
|
||||
\`\`\`bash
|
||||
# Check for test commands
|
||||
cat package.json | jq '.scripts | keys[] | select(test("test"))'
|
||||
|
||||
# Or for Python
|
||||
ls -la pytest.ini pyproject.toml setup.cfg
|
||||
|
||||
# Or for Go
|
||||
ls -la *_test.go
|
||||
\`\`\`
|
||||
|
||||
## 3.2: Analyze Test Coverage
|
||||
|
||||
\`\`\`
|
||||
// Find all tests related to target
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=false, // Need this synchronously
|
||||
prompt="Analyze test coverage for [TARGET]:
|
||||
1. Which test files cover this code?
|
||||
2. What test cases exist?
|
||||
3. Are there integration tests?
|
||||
4. What edge cases are tested?
|
||||
5. Estimated coverage percentage?"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 3.3: Determine Verification Strategy
|
||||
|
||||
Based on test analysis:
|
||||
|
||||
| Coverage Level | Strategy |
|
||||
|----------------|----------|
|
||||
| HIGH (>80%) | Run existing tests after each step |
|
||||
| MEDIUM (50-80%) | Run tests + add safety assertions |
|
||||
| LOW (<50%) | **PAUSE**: Propose adding tests first |
|
||||
| NONE | **BLOCK**: Refuse aggressive refactoring |
|
||||
|
||||
**If coverage is LOW or NONE, ask user:**
|
||||
|
||||
\`\`\`
|
||||
Test coverage for [TARGET] is [LEVEL].
|
||||
|
||||
**Risk Assessment**: Refactoring without adequate tests is dangerous.
|
||||
|
||||
Options:
|
||||
1. Add tests first, then refactor (RECOMMENDED)
|
||||
2. Proceed with extra caution, manual verification required
|
||||
3. Abort refactoring
|
||||
|
||||
Which approach do you prefer?
|
||||
\`\`\`
|
||||
|
||||
## 3.4: Document Verification Plan
|
||||
|
||||
\`\`\`
|
||||
## VERIFICATION PLAN
|
||||
|
||||
### Test Commands
|
||||
- Unit: \`bun test\` / \`npm test\` / \`pytest\` / etc.
|
||||
- Integration: [command if exists]
|
||||
- Type check: \`tsc --noEmit\` / \`pyright\` / etc.
|
||||
|
||||
### Verification Checkpoints
|
||||
After each refactoring step:
|
||||
1. lsp_diagnostics → zero new errors
|
||||
2. Run test command → all pass
|
||||
3. Type check → clean
|
||||
|
||||
### Regression Indicators
|
||||
- [Specific test that must pass]
|
||||
- [Behavior that must be preserved]
|
||||
- [API contract that must not change]
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-3 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 4: PLAN GENERATION (PLAN AGENT)
|
||||
|
||||
**Mark phase-4 as in_progress.**
|
||||
|
||||
## 4.1: Invoke Plan Agent
|
||||
|
||||
\`\`\`
|
||||
Task(
|
||||
subagent_type="plan",
|
||||
prompt="Create a detailed refactoring plan:
|
||||
|
||||
## Refactoring Goal
|
||||
[User's original request]
|
||||
|
||||
## Codemap (from Phase 2)
|
||||
[Insert codemap here]
|
||||
|
||||
## Test Coverage (from Phase 3)
|
||||
[Insert verification plan here]
|
||||
|
||||
## Constraints
|
||||
- MUST follow existing patterns: [list]
|
||||
- MUST NOT break: [critical paths]
|
||||
- MUST run tests after each step
|
||||
|
||||
## Requirements
|
||||
1. Break down into atomic refactoring steps
|
||||
2. Each step must be independently verifiable
|
||||
3. Order steps by dependency (what must happen first)
|
||||
4. Specify exact files and line ranges for each step
|
||||
5. Include rollback strategy for each step
|
||||
6. Define commit checkpoints"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 4.2: Review and Validate Plan
|
||||
|
||||
After receiving plan from Plan agent:
|
||||
|
||||
1. **Verify completeness**: All identified files addressed?
|
||||
2. **Verify safety**: Each step reversible?
|
||||
3. **Verify order**: Dependencies respected?
|
||||
4. **Verify verification**: Test commands specified?
|
||||
|
||||
## 4.3: Register Detailed Todos
|
||||
|
||||
Convert Plan agent output into granular todos:
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
// Each step from the plan becomes a todo
|
||||
{"id": "refactor-1", "content": "Step 1: [description]", "status": "pending", "priority": "high"},
|
||||
{"id": "verify-1", "content": "Verify Step 1: run tests", "status": "pending", "priority": "high"},
|
||||
{"id": "refactor-2", "content": "Step 2: [description]", "status": "pending", "priority": "medium"},
|
||||
{"id": "verify-2", "content": "Verify Step 2: run tests", "status": "pending", "priority": "medium"},
|
||||
// ... continue for all steps
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-4 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 5: EXECUTE REFACTORING (DETERMINISTIC EXECUTION)
|
||||
|
||||
**Mark phase-5 as in_progress.**
|
||||
|
||||
## 5.1: Execution Protocol
|
||||
|
||||
For EACH refactoring step:
|
||||
|
||||
### Pre-Step
|
||||
1. Mark step todo as \`in_progress\`
|
||||
2. Read current file state
|
||||
3. Verify lsp_diagnostics is baseline
|
||||
|
||||
### Execute Step
|
||||
Use appropriate tool:
|
||||
|
||||
**For Symbol Renames:**
|
||||
\`\`\`typescript
|
||||
lsp_prepare_rename(filePath, line, character) // Validate rename is possible
|
||||
lsp_rename(filePath, line, character, newName) // Execute rename
|
||||
\`\`\`
|
||||
|
||||
**For Pattern Transformations:**
|
||||
\`\`\`typescript
|
||||
// Preview first
|
||||
ast_grep_replace(pattern, rewrite, lang, dryRun=true)
|
||||
|
||||
// If preview looks good, execute
|
||||
ast_grep_replace(pattern, rewrite, lang, dryRun=false)
|
||||
\`\`\`
|
||||
|
||||
**For Structural Changes:**
|
||||
\`\`\`typescript
|
||||
// Use Edit tool for precise changes
|
||||
edit(filePath, oldString, newString)
|
||||
\`\`\`
|
||||
|
||||
### Post-Step Verification (MANDATORY)
|
||||
|
||||
\`\`\`typescript
|
||||
// 1. Check diagnostics
|
||||
lsp_diagnostics(filePath) // Must be clean or same as baseline
|
||||
|
||||
// 2. Run tests
|
||||
bash("bun test") // Or appropriate test command
|
||||
|
||||
// 3. Type check
|
||||
bash("tsc --noEmit") // Or appropriate type check
|
||||
\`\`\`
|
||||
|
||||
### Step Completion
|
||||
1. If verification passes → Mark step todo as \`completed\`
|
||||
2. If verification fails → **STOP AND FIX**
|
||||
|
||||
## 5.2: Failure Recovery Protocol
|
||||
|
||||
If ANY verification fails:
|
||||
|
||||
1. **STOP** immediately
|
||||
2. **REVERT** the failed change
|
||||
3. **DIAGNOSE** what went wrong
|
||||
4. **OPTIONS**:
|
||||
- Fix the issue and retry
|
||||
- Skip this step (if optional)
|
||||
- Consult oracle agent for help
|
||||
- Ask user for guidance
|
||||
|
||||
**NEVER proceed to next step with broken tests.**
|
||||
|
||||
## 5.3: Commit Checkpoints
|
||||
|
||||
After each logical group of changes:
|
||||
|
||||
\`\`\`bash
|
||||
git add [changed-files]
|
||||
git commit -m "refactor(scope): description
|
||||
|
||||
[details of what was changed and why]"
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-5 as completed when all refactoring steps done.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 6: FINAL VERIFICATION (REGRESSION CHECK)
|
||||
|
||||
**Mark phase-6 as in_progress.**
|
||||
|
||||
## 6.1: Full Test Suite
|
||||
|
||||
\`\`\`bash
|
||||
# Run complete test suite
|
||||
bun test # or npm test, pytest, go test, etc.
|
||||
\`\`\`
|
||||
|
||||
## 6.2: Type Check
|
||||
|
||||
\`\`\`bash
|
||||
# Full type check
|
||||
tsc --noEmit # or equivalent
|
||||
\`\`\`
|
||||
|
||||
## 6.3: Lint Check
|
||||
|
||||
\`\`\`bash
|
||||
# Run linter
|
||||
eslint . # or equivalent
|
||||
\`\`\`
|
||||
|
||||
## 6.4: Build Verification (if applicable)
|
||||
|
||||
\`\`\`bash
|
||||
# Ensure build still works
|
||||
bun run build # or npm run build, etc.
|
||||
\`\`\`
|
||||
|
||||
## 6.5: Final Diagnostics
|
||||
|
||||
\`\`\`typescript
|
||||
// Check all changed files
|
||||
for (file of changedFiles) {
|
||||
lsp_diagnostics(file) // Must all be clean
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 6.6: Generate Summary
|
||||
|
||||
\`\`\`markdown
|
||||
## Refactoring Complete
|
||||
|
||||
### What Changed
|
||||
- [List of changes made]
|
||||
|
||||
### Files Modified
|
||||
- \`path/to/file.ts\` - [what changed]
|
||||
- \`path/to/file2.ts\` - [what changed]
|
||||
|
||||
### Verification Results
|
||||
- Tests: PASSED (X/Y passing)
|
||||
- Type Check: CLEAN
|
||||
- Lint: CLEAN
|
||||
- Build: SUCCESS
|
||||
|
||||
### No Regressions Detected
|
||||
All existing tests pass. No new errors introduced.
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-6 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# CRITICAL RULES
|
||||
|
||||
## NEVER DO
|
||||
- Skip lsp_diagnostics check after changes
|
||||
- Proceed with failing tests
|
||||
- Make changes without understanding impact
|
||||
- Use \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
|
||||
- Delete tests to make them pass
|
||||
- Commit broken code
|
||||
- Refactor without understanding existing patterns
|
||||
|
||||
## ALWAYS DO
|
||||
- Understand before changing
|
||||
- Preview before applying (ast_grep dryRun=true)
|
||||
- Verify after every change
|
||||
- Follow existing codebase patterns
|
||||
- Keep todos updated in real-time
|
||||
- Commit at logical checkpoints
|
||||
- Report issues immediately
|
||||
|
||||
## ABORT CONDITIONS
|
||||
If any of these occur, **STOP and consult user**:
|
||||
- Test coverage is zero for target code
|
||||
- Changes would break public API
|
||||
- Refactoring scope is unclear
|
||||
- 3 consecutive verification failures
|
||||
- User-defined constraints violated
|
||||
|
||||
---
|
||||
|
||||
# Tool Usage Philosophy
|
||||
|
||||
You already know these tools. Use them intelligently:
|
||||
|
||||
## LSP Tools
|
||||
Leverage the full LSP toolset (\`lsp_*\`) for precision analysis. Key patterns:
|
||||
- **Understand before changing**: \`lsp_hover\`, \`lsp_goto_definition\` to grasp context
|
||||
- **Impact analysis**: \`lsp_find_references\` to map all usages before modification
|
||||
- **Safe refactoring**: \`lsp_prepare_rename\` → \`lsp_rename\` for symbol renames
|
||||
- **Continuous verification**: \`lsp_diagnostics\` after every change
|
||||
|
||||
## AST-Grep
|
||||
Use \`ast_grep_search\` and \`ast_grep_replace\` for structural transformations.
|
||||
**Critical**: Always \`dryRun=true\` first, review, then execute.
|
||||
|
||||
## Agents
|
||||
- \`explore\`: Parallel codebase pattern discovery
|
||||
- \`plan\`: Detailed refactoring plan generation
|
||||
- \`oracle\`: Consult for complex architectural decisions
|
||||
- \`librarian\`: **Use proactively** when encountering deprecated methods or library migration tasks. Query official docs and OSS examples for modern replacements.
|
||||
|
||||
## Deprecated Code & Library Migration
|
||||
When you encounter deprecated methods/APIs during refactoring:
|
||||
1. Fire \`librarian\` to find the recommended modern alternative
|
||||
2. **DO NOT auto-upgrade to latest version** unless user explicitly requests migration
|
||||
3. If user requests library migration, use \`librarian\` to fetch latest API docs before making changes
|
||||
|
||||
---
|
||||
|
||||
**Remember: Refactoring without tests is reckless. Refactoring without understanding is destructive. This command ensures you do neither.**
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>
|
||||
`
|
||||
@@ -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" | "ralph-loop" | "cancel-ralph" | "refactor"
|
||||
|
||||
export interface BuiltinCommandConfig {
|
||||
disabled_commands?: BuiltinCommandName[]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BuiltinSkill } from "./types"
|
||||
|
||||
const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
|
||||
description: "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
|
||||
import { promises as fsPromises } from "fs"
|
||||
import { promises as fs, type Dirent } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { homedir } from "os"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
@@ -8,19 +8,21 @@ import { getClaudeConfigDir } from "../../shared"
|
||||
import { log } from "../../shared/logger"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function loadCommandsFromDir(
|
||||
async function loadCommandsFromDir(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
visited: Set<string> = new Set(),
|
||||
prefix: string = ""
|
||||
): LoadedCommand[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
): Promise<LoadedCommand[]> {
|
||||
try {
|
||||
await fs.access(commandsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = realpathSync(commandsDir)
|
||||
realPath = await fs.realpath(commandsDir)
|
||||
} catch (error) {
|
||||
log(`Failed to resolve command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
@@ -33,7 +35,7 @@ function loadCommandsFromDir(
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
entries = await fs.readdir(commandsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
log(`Failed to read command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
@@ -46,7 +48,8 @@ function loadCommandsFromDir(
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const subDirPath = join(commandsDir, entry.name)
|
||||
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
|
||||
commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix))
|
||||
const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix)
|
||||
commands.push(...subCommands)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ function loadCommandsFromDir(
|
||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const content = await fs.readFile(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const wrappedTemplate = `<command-instruction>
|
||||
@@ -106,154 +109,36 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadUserCommands(): Record<string, CommandDefinition> {
|
||||
export async function loadUserCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const commands = loadCommandsFromDir(userCommandsDir, "user")
|
||||
const commands = await loadCommandsFromDir(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadProjectCommands(): Record<string, CommandDefinition> {
|
||||
export async function loadProjectCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(projectCommandsDir, "project")
|
||||
const commands = await loadCommandsFromDir(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
|
||||
const { homedir } = require("os")
|
||||
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
|
||||
export async function loadOpencodeProjectCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
async function loadCommandsFromDirAsync(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
visited: Set<string> = new Set(),
|
||||
prefix: string = ""
|
||||
): Promise<LoadedCommand[]> {
|
||||
try {
|
||||
await fsPromises.access(commandsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = await fsPromises.realpath(commandsDir)
|
||||
} catch (error) {
|
||||
log(`Failed to resolve command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
if (visited.has(realPath)) {
|
||||
return []
|
||||
}
|
||||
visited.add(realPath)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fsPromises.readdir(commandsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
log(`Failed to read command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const subDirPath = join(commandsDir, entry.name)
|
||||
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
|
||||
const subCommands = await loadCommandsFromDirAsync(subDirPath, scope, visited, subPrefix)
|
||||
commands.push(...subCommands)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const baseCommandName = basename(entry.name, ".md")
|
||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const wrappedTemplate = `<command-instruction>
|
||||
${body.trim()}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const formattedDescription = `(${scope}) ${data.description || ""}`
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const definition: CommandDefinition = {
|
||||
name: commandName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`Failed to parse command: ${commandPath}`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
export async function loadUserCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const commands = await loadCommandsFromDirAsync(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = await loadCommandsFromDirAsync(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeGlobalCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const { homedir } = require("os")
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = await loadCommandsFromDirAsync(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = await loadCommandsFromDirAsync(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadAllCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
export async function loadAllCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const [user, project, global, projectOpencode] = await Promise.all([
|
||||
loadUserCommandsAsync(),
|
||||
loadProjectCommandsAsync(),
|
||||
loadOpencodeGlobalCommandsAsync(),
|
||||
loadOpencodeProjectCommandsAsync(),
|
||||
loadUserCommands(),
|
||||
loadProjectCommands(),
|
||||
loadOpencodeGlobalCommands(),
|
||||
loadOpencodeProjectCommands(),
|
||||
])
|
||||
return { ...projectOpencode, ...global, ...project, ...user }
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ This is the skill body.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "test-skill")
|
||||
|
||||
// #then
|
||||
@@ -89,7 +89,7 @@ This is a simple skill.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "simple-skill")
|
||||
|
||||
// #then
|
||||
@@ -122,7 +122,7 @@ Skill with env vars.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "env-skill")
|
||||
|
||||
// #then
|
||||
@@ -149,7 +149,7 @@ Skill body.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
// #then - when YAML fails, skill uses directory name as fallback
|
||||
const skill = skills.find(s => s.name === "bad-yaml-skill")
|
||||
|
||||
@@ -186,7 +186,7 @@ Skill body.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "ampcode-skill")
|
||||
|
||||
// #then
|
||||
@@ -227,7 +227,7 @@ Skill body.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "priority-skill")
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
@@ -259,7 +259,7 @@ Skill body.
|
||||
process.chdir(TEST_DIR)
|
||||
|
||||
try {
|
||||
const skills = discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skills = await discoverSkills({ includeClaudeCodePaths: false })
|
||||
const skill = skills.find(s => s.name === "direct-format")
|
||||
|
||||
// #then
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { promises as fs } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { homedir } from "os"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
|
||||
@@ -26,20 +25,17 @@ function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | u
|
||||
return undefined
|
||||
}
|
||||
|
||||
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
|
||||
async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | undefined> {
|
||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||
if (!existsSync(mcpJsonPath)) return undefined
|
||||
|
||||
|
||||
try {
|
||||
const content = readFileSync(mcpJsonPath, "utf-8")
|
||||
const content = await fs.readFile(mcpJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
|
||||
// AmpCode format: { "mcpServers": { "name": { ... } } }
|
||||
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
|
||||
return parsed.mcpServers as SkillMcpConfig
|
||||
}
|
||||
|
||||
// Also support direct format: { "name": { command: ..., args: ... } }
|
||||
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
|
||||
const hasCommandField = Object.values(parsed).some(
|
||||
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
|
||||
@@ -59,77 +55,7 @@ function parseAllowedTools(allowedTools: string | undefined): string[] | undefin
|
||||
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 } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
// Lazy content loader - only loads template on first use
|
||||
const lazyContent: LazyContentLoader = {
|
||||
loaded: false,
|
||||
content: undefined,
|
||||
load: async () => {
|
||||
if (!lazyContent.loaded) {
|
||||
const fileContent = await fs.readFile(skillPath, "utf-8")
|
||||
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
|
||||
lazyContent.content = `<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>`
|
||||
lazyContent.loaded = true
|
||||
}
|
||||
return lazyContent.content!
|
||||
},
|
||||
}
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: "", // Empty at startup, loaded lazily
|
||||
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"]),
|
||||
mcpConfig,
|
||||
lazyContent,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkillFromPathAsync(
|
||||
async function loadSkillFromPath(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
@@ -139,7 +65,7 @@ async function loadSkillFromPathAsync(
|
||||
const content = await fs.readFile(skillPath, "utf-8")
|
||||
const { data } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
@@ -198,60 +124,7 @@ $ARGUMENTS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of loadSkillsFromDir using Promise-based fs operations.
|
||||
*/
|
||||
async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
|
||||
async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||
const skills: LoadedSkill[] = []
|
||||
|
||||
@@ -261,13 +134,13 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const resolvedPath = await resolveSymlinkAsync(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope)
|
||||
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
@@ -276,7 +149,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
@@ -287,7 +160,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope)
|
||||
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
}
|
||||
@@ -304,154 +177,86 @@ function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from Claude Code user directory (~/.claude/skills/)
|
||||
*/
|
||||
export function loadUserSkills(): Record<string, CommandDefinition> {
|
||||
export async function loadUserSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
const skills = await loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from Claude Code project directory (.claude/skills/)
|
||||
*/
|
||||
export function loadProjectSkills(): Record<string, CommandDefinition> {
|
||||
export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(projectSkillsDir, "project")
|
||||
const skills = await loadSkillsFromDir(projectSkillsDir, "project")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from OpenCode global directory (~/.config/opencode/skill/)
|
||||
*/
|
||||
export function loadOpencodeGlobalSkills(): Record<string, CommandDefinition> {
|
||||
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
return skillsToRecord(skills)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load skills from OpenCode project directory (.opencode/skill/)
|
||||
*/
|
||||
export function loadOpencodeProjectSkills(): Record<string, CommandDefinition> {
|
||||
export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
const skills = await 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")
|
||||
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
||||
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverProjectClaudeSkills(),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
discoverUserClaudeSkills(),
|
||||
])
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
export async function discoverUserClaudeSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDirAsync(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export async function discoverProjectClaudeSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDirAsync(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGlobalSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDirAsync(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeProjectSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
export async function discoverAllSkillsAsync(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||
const { includeClaudeCodePaths = true } = options
|
||||
|
||||
const opencodeProjectSkills = await discoverOpencodeProjectSkillsAsync()
|
||||
const opencodeGlobalSkills = await discoverOpencodeGlobalSkillsAsync()
|
||||
const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
])
|
||||
|
||||
if (!includeClaudeCodePaths) {
|
||||
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
|
||||
}
|
||||
|
||||
const [projectSkills, userSkills] = await Promise.all([
|
||||
discoverProjectClaudeSkillsAsync(),
|
||||
discoverUserClaudeSkillsAsync(),
|
||||
discoverProjectClaudeSkills(),
|
||||
discoverUserClaudeSkills(),
|
||||
])
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
}
|
||||
|
||||
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
||||
const skills = await discoverSkills(options)
|
||||
return skills.find(s => s.name === name)
|
||||
}
|
||||
|
||||
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDir(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDir(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export const TARGET_TOOLS = new Set([
|
||||
"webfetch",
|
||||
"context7_resolve-library-id",
|
||||
"context7_get-library-docs",
|
||||
"websearch_exa_web_search_exa",
|
||||
"grep_app_searchgithub",
|
||||
]);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
interface CommandScope {
|
||||
@@ -32,6 +32,7 @@ interface CommandInfo {
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope["type"]
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
||||
@@ -91,10 +92,15 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: "skill",
|
||||
lazyContentLoader: skill.lazyContent,
|
||||
}
|
||||
}
|
||||
|
||||
function discoverAllCommands(): CommandInfo[] {
|
||||
export interface ExecutorOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
||||
@@ -105,7 +111,7 @@ function discoverAllCommands(): CommandInfo[] {
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
|
||||
const skills = discoverAllSkills()
|
||||
const skills = options?.skills ?? await discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
@@ -117,8 +123,8 @@ function discoverAllCommands(): CommandInfo[] {
|
||||
]
|
||||
}
|
||||
|
||||
function findCommand(commandName: string): CommandInfo | null {
|
||||
const allCommands = discoverAllCommands()
|
||||
async function findCommand(commandName: string, options?: ExecutorOptions): Promise<CommandInfo | null> {
|
||||
const allCommands = await discoverAllCommands(options)
|
||||
return allCommands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
|
||||
) ?? null
|
||||
@@ -149,8 +155,13 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<st
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
let content = cmd.content || ""
|
||||
if (!content && cmd.lazyContentLoader) {
|
||||
content = await cmd.lazyContentLoader.load()
|
||||
}
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
@@ -169,8 +180,8 @@ export interface ExecuteResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
|
||||
const command = findCommand(parsed.command)
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand, options?: ExecutorOptions): Promise<ExecuteResult> {
|
||||
const command = await findCommand(parsed.command, options)
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
@@ -20,7 +21,15 @@ export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
|
||||
export function createAutoSlashCommandHook() {
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
@@ -52,7 +61,7 @@ export function createAutoSlashCommandHook() {
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed)
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx < 0) {
|
||||
|
||||
@@ -138,7 +138,7 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
return
|
||||
}
|
||||
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt))
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
|
||||
const keywordMessages = detectedKeywords.map((k) => k.message)
|
||||
|
||||
if (keywordMessages.length > 0) {
|
||||
|
||||
@@ -1,96 +1,199 @@
|
||||
export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: `<ultrawork-mode>
|
||||
const ULTRAWORK_PLANNER_SECTION = `## CRITICAL: YOU ARE A PLANNER, NOT AN IMPLEMENTER
|
||||
|
||||
## TODO IS YOUR LIFELINE (NON-NEGOTIABLE)
|
||||
**IDENTITY CONSTRAINT (NON-NEGOTIABLE):**
|
||||
You ARE the planner. You ARE NOT an implementer. You DO NOT write code. You DO NOT execute tasks.
|
||||
|
||||
**USE TodoWrite OBSESSIVELY. This is the #1 most important tool.**
|
||||
**TOOL RESTRICTIONS (SYSTEM-ENFORCED):**
|
||||
| Tool | Allowed | Blocked |
|
||||
|------|---------|---------|
|
||||
| Write/Edit | \`.sisyphus/**/*.md\` ONLY | Everything else |
|
||||
| Read | All files | - |
|
||||
| Bash | Research commands only | Implementation commands |
|
||||
| sisyphus_task | explore, librarian | - |
|
||||
|
||||
### TODO Rules
|
||||
1. **BEFORE any action**: Create TODOs FIRST. Break down into atomic, granular steps.
|
||||
2. **Be excessively detailed**: 10 small TODOs > 3 vague TODOs. Err on the side of too many.
|
||||
3. **Real-time updates**: Mark \`in_progress\` before starting, \`completed\` IMMEDIATELY after. NEVER batch.
|
||||
4. **One at a time**: Only ONE TODO should be \`in_progress\` at any moment.
|
||||
5. **Sub-tasks**: Complex TODO? Break it into sub-TODOs. Keep granularity high.
|
||||
6. **Questions too**: User asks a question? TODO: "Answer with evidence: [question]"
|
||||
**IF YOU TRY TO WRITE/EDIT OUTSIDE \`.sisyphus/\`:**
|
||||
- System will BLOCK your action
|
||||
- You will receive an error
|
||||
- DO NOT retry - you are not supposed to implement
|
||||
|
||||
### Example TODO Granularity
|
||||
BAD: "Implement user auth"
|
||||
GOOD:
|
||||
- "Read existing auth patterns in codebase"
|
||||
- "Create auth schema types"
|
||||
- "Implement login endpoint"
|
||||
- "Implement token validation middleware"
|
||||
- "Add auth tests - login success case"
|
||||
- "Add auth tests - login failure case"
|
||||
- "Verify LSP diagnostics clean"
|
||||
**YOUR ONLY WRITABLE PATHS:**
|
||||
- \`.sisyphus/plans/*.md\` - Final work plans
|
||||
- \`.sisyphus/drafts/*.md\` - Working drafts during interview
|
||||
|
||||
**YOUR WORK IS INVISIBLE WITHOUT TODOs. USE THEM.**
|
||||
**WHEN USER ASKS YOU TO IMPLEMENT:**
|
||||
REFUSE. Say: "I'm a planner. I create work plans, not implementations. Run \`/start-work\` after I finish planning."
|
||||
|
||||
## TDD WORKFLOW (MANDATORY when tests exist)
|
||||
---
|
||||
|
||||
Check for test infrastructure FIRST. If exists, follow strictly:
|
||||
## CONTEXT GATHERING (MANDATORY BEFORE PLANNING)
|
||||
|
||||
1. **RED**: Write failing test FIRST → \`bun test\` must FAIL
|
||||
2. **GREEN**: Write MINIMAL code to pass → \`bun test\` must PASS
|
||||
3. **REFACTOR**: Clean up, tests stay green → \`bun test\` still PASS
|
||||
4. **REPEAT**: Next test case, loop until complete
|
||||
You ARE the planner. Your job: create bulletproof work plans.
|
||||
**Before drafting ANY plan, gather context via explore/librarian agents.**
|
||||
|
||||
**NEVER write implementation before test. NEVER delete failing tests.**
|
||||
### Research Protocol
|
||||
1. **Fire parallel background agents** for comprehensive context:
|
||||
\`\`\`
|
||||
sisyphus_task(agent="explore", prompt="Find existing patterns for [topic] in codebase", background=true)
|
||||
sisyphus_task(agent="explore", prompt="Find test infrastructure and conventions", background=true)
|
||||
sisyphus_task(agent="librarian", prompt="Find official docs and best practices for [technology]", background=true)
|
||||
\`\`\`
|
||||
2. **Wait for results** before planning - rushed plans fail
|
||||
3. **Synthesize findings** into informed requirements
|
||||
|
||||
## AGENT DEPLOYMENT
|
||||
### What to Research
|
||||
- Existing codebase patterns and conventions
|
||||
- Test infrastructure (TDD possible?)
|
||||
- External library APIs and constraints
|
||||
- Similar implementations in OSS (via librarian)
|
||||
|
||||
Fire available agents in PARALLEL via background tasks. Use explore/librarian agents liberally (multiple concurrent if needed).
|
||||
**NEVER plan blind. Context first, plan second.**`
|
||||
|
||||
## EVIDENCE-BASED ANSWERS
|
||||
/**
|
||||
* Determines if the agent is a planner-type agent.
|
||||
* Planner agents should NOT be told to call plan agent (they ARE the planner).
|
||||
*/
|
||||
function isPlannerAgent(agentName?: string): boolean {
|
||||
if (!agentName) return false
|
||||
const lowerName = agentName.toLowerCase()
|
||||
return lowerName.includes("prometheus") || lowerName.includes("planner") || lowerName === "plan"
|
||||
}
|
||||
|
||||
- Every claim: code snippet + file path + line number
|
||||
- No "I think..." - find and SHOW actual code
|
||||
- Local search fails? → librarian for external sources
|
||||
- **NEVER acceptable**: "I couldn't find it"
|
||||
/**
|
||||
* Generates the ultrawork message based on agent context.
|
||||
* Planner agents get context-gathering focused instructions.
|
||||
* Other agents get the original strong agent utilization instructions.
|
||||
*/
|
||||
export function getUltraworkMessage(agentName?: string): string {
|
||||
const isPlanner = isPlannerAgent(agentName)
|
||||
|
||||
## ZERO TOLERANCE FOR SHORTCUTS (RIGOROUS & HONEST EXECUTION)
|
||||
if (isPlanner) {
|
||||
return `<ultrawork-mode>
|
||||
|
||||
**CORE PRINCIPLE**: Execute user's ORIGINAL INTENT with maximum rigor. No shortcuts. No compromises. No matter how large the task.
|
||||
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
|
||||
|
||||
### ABSOLUTE PROHIBITIONS
|
||||
| Violation | Why It's Forbidden |
|
||||
|-----------|-------------------|
|
||||
| **Mocking/Stubbing** | Never use mocks, stubs, or fake implementations unless explicitly requested. Real implementation only. |
|
||||
| **Scope Reduction** | Never make "demo", "skeleton", "simplified", "basic", "minimal" versions. Deliver FULL implementation. |
|
||||
| **Partial Completion** | Never stop at 60-80% saying "you can extend this...", "as an exercise...", "you can add...". Finish 100%. |
|
||||
| **Lazy Placeholders** | Never use "// TODO", "...", "etc.", "and so on" in actual code. Complete everything. |
|
||||
| **Assumed Shortcuts** | Never skip requirements deemed "optional" or "can be added later". All requirements are mandatory. |
|
||||
| **Test Deletion** | Never delete or skip failing tests. Fix the code, not the tests. |
|
||||
| **Evidence-Free Claims** | Never say "I think...", "probably...", "should work...". Show actual code/output. |
|
||||
|
||||
### RIGOROUS EXECUTION MANDATE
|
||||
1. **Parse Original Intent**: What did the user ACTUALLY want? Not what's convenient. The REAL, COMPLETE request.
|
||||
2. **No Task Too Large**: If the task requires 100 files, modify 100 files. If it needs 1000 lines, write 1000 lines. Size is irrelevant.
|
||||
3. **Honest Assessment**: If you cannot complete something, say so BEFORE starting. Don't fake completion.
|
||||
4. **Evidence-Based Verification**: Every claim backed by code snippets, file paths, line numbers, and actual outputs.
|
||||
5. **Complete Verification**: Re-read original request after completion. Check EVERY requirement was met.
|
||||
|
||||
### FAILURE RECOVERY
|
||||
If you realize you've taken shortcuts:
|
||||
1. STOP immediately
|
||||
2. Identify what you skipped/faked
|
||||
3. Create TODOs for ALL remaining work
|
||||
4. Execute to TRUE completion - not "good enough"
|
||||
|
||||
**THE USER ASKED FOR X. DELIVER EXACTLY X. COMPLETELY. HONESTLY. NO MATTER THE SIZE.**
|
||||
|
||||
## SUCCESS = All TODOs Done + All Requirements Met + Evidence Provided
|
||||
${ULTRAWORK_PLANNER_SECTION}
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`,
|
||||
`
|
||||
}
|
||||
|
||||
return `<ultrawork-mode>
|
||||
|
||||
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
|
||||
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
|
||||
|
||||
**NOTHING is "done" without PROOF it works.**
|
||||
|
||||
### Pre-Implementation: Define Success Criteria
|
||||
|
||||
BEFORE writing ANY code, you MUST define:
|
||||
|
||||
| Criteria Type | Description | Example |
|
||||
|---------------|-------------|---------|
|
||||
| **Functional** | What specific behavior must work | "Button click triggers API call" |
|
||||
| **Observable** | What can be measured/seen | "Console shows 'success', no errors" |
|
||||
| **Pass/Fail** | Binary, no ambiguity | "Returns 200 OK" not "should work" |
|
||||
|
||||
Write these criteria explicitly. Share with user if scope is non-trivial.
|
||||
|
||||
### Test Plan Template (MANDATORY for non-trivial tasks)
|
||||
|
||||
\`\`\`
|
||||
## Test Plan
|
||||
### Objective: [What we're verifying]
|
||||
### Prerequisites: [Setup needed]
|
||||
### Test Cases:
|
||||
1. [Test Name]: [Input] → [Expected Output] → [How to verify]
|
||||
2. ...
|
||||
### Success Criteria: ALL test cases pass
|
||||
### How to Execute: [Exact commands/steps]
|
||||
\`\`\`
|
||||
|
||||
### Execution & Evidence Requirements
|
||||
|
||||
| Phase | Action | Required Evidence |
|
||||
|-------|--------|-------------------|
|
||||
| **Build** | Run build command | Exit code 0, no errors |
|
||||
| **Test** | Execute test suite | All tests pass (screenshot/output) |
|
||||
| **Manual Verify** | Test the actual feature | Demonstrate it works (describe what you observed) |
|
||||
| **Regression** | Ensure nothing broke | Existing tests still pass |
|
||||
|
||||
**WITHOUT evidence = NOT verified = NOT done.**
|
||||
|
||||
### TDD Workflow (when test infrastructure exists)
|
||||
|
||||
1. **SPEC**: Define what "working" means (success criteria above)
|
||||
2. **RED**: Write failing test → Run it → Confirm it FAILS
|
||||
3. **GREEN**: Write minimal code → Run test → Confirm it PASSES
|
||||
4. **REFACTOR**: Clean up → Tests MUST stay green
|
||||
5. **VERIFY**: Run full test suite, confirm no regressions
|
||||
6. **EVIDENCE**: Report what you ran and what output you saw
|
||||
|
||||
### Verification Anti-Patterns (BLOCKING)
|
||||
|
||||
| Violation | Why It Fails |
|
||||
|-----------|--------------|
|
||||
| "It should work now" | No evidence. Run it. |
|
||||
| "I added the tests" | Did they pass? Show output. |
|
||||
| "Fixed the bug" | How do you know? What did you test? |
|
||||
| "Implementation complete" | Did you verify against success criteria? |
|
||||
| Skipping test execution | Tests exist to be RUN, not just written |
|
||||
|
||||
**CLAIM NOTHING WITHOUT PROOF. EXECUTE. VERIFY. SHOW EVIDENCE.**
|
||||
|
||||
## ZERO TOLERANCE FAILURES
|
||||
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
||||
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
|
||||
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
||||
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
||||
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
||||
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
`
|
||||
}
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string | ((agentName?: string) => string) }> = [
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: getUltraworkMessage,
|
||||
},
|
||||
// SEARCH: EN/KO/JP/CN/VN
|
||||
{
|
||||
|
||||
@@ -13,20 +13,30 @@ export function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
|
||||
}
|
||||
|
||||
export function detectKeywords(text: string): string[] {
|
||||
/**
|
||||
* Resolves message to string, handling both static strings and dynamic functions.
|
||||
*/
|
||||
function resolveMessage(
|
||||
message: string | ((agentName?: string) => string),
|
||||
agentName?: string
|
||||
): string {
|
||||
return typeof message === "function" ? message(agentName) : message
|
||||
}
|
||||
|
||||
export function detectKeywords(text: string, agentName?: string): string[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
return KEYWORD_DETECTORS.filter(({ pattern }) =>
|
||||
pattern.test(textWithoutCode)
|
||||
).map(({ message }) => message)
|
||||
).map(({ message }) => resolveMessage(message, agentName))
|
||||
}
|
||||
|
||||
export function detectKeywordsWithType(text: string): DetectedKeyword[] {
|
||||
export function detectKeywordsWithType(text: string, agentName?: string): DetectedKeyword[] {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
|
||||
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
|
||||
matches: pattern.test(textWithoutCode),
|
||||
type: types[index],
|
||||
message,
|
||||
message: resolveMessage(message, agentName),
|
||||
}))
|
||||
.filter((result) => result.matches)
|
||||
.map(({ type, message }) => ({ type, message }))
|
||||
|
||||
125
src/hooks/keyword-detector/index.test.ts
Normal file
125
src/hooks/keyword-detector/index.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => {
|
||||
// #given - main session is set, different session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be filtered out based on mainSessionID comparison
|
||||
const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session"))
|
||||
expect(skipLog).toBeDefined()
|
||||
})
|
||||
|
||||
test("should allow ultrawork keywords in non-main session", async () => {
|
||||
// #given - main session is set, different session submits ultrawork keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork mode" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should still work (variant set to max)
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should allow all keywords in main session", async () => {
|
||||
// #given - main session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: mainSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be detected (output unchanged but detection happens)
|
||||
// Note: search keywords don't set variant, they inject messages via context-injector
|
||||
// This test verifies the detection logic runs without filtering
|
||||
expect(output.message.variant).toBeUndefined() // search doesn't set variant
|
||||
})
|
||||
|
||||
test("should allow all keywords when mainSessionID is not set", async () => {
|
||||
// #given - no main session set (early startup or standalone mode)
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork search" }],
|
||||
}
|
||||
|
||||
// #when - any session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - all keywords should work
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
@@ -21,16 +22,34 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
}
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText))
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only ultrawork keywords work in non-main sessions
|
||||
// Other keywords (search, analyze, etc.) only work in main sessions
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
if (isNonMainSession) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
|
||||
if (detectedKeywords.length === 0) {
|
||||
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
|
||||
sessionID: input.sessionID,
|
||||
mainSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
output.message.variant = "max"
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import * as childProcess from "node:child_process"
|
||||
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
@@ -6,20 +8,11 @@ import * as utils from "./session-notification-utils"
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
let spawnMock: ReturnType<typeof spyOn>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
// #given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
@@ -32,6 +25,18 @@ describe("session-notification", () => {
|
||||
beforeEach(() => {
|
||||
notificationCalls = []
|
||||
|
||||
// Mock spawn to track notification commands
|
||||
// Uses node:child_process.spawn instead of Bun shell to avoid GC crash
|
||||
spawnMock = spyOn(childProcess, "spawn").mockImplementation(((cmd: string, args?: string[]) => {
|
||||
// Track notification commands (osascript, notify-send, powershell)
|
||||
if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) {
|
||||
notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`)
|
||||
}
|
||||
const emitter = new EventEmitter()
|
||||
setTimeout(() => emitter.emit("close", 0), 0)
|
||||
return emitter as any
|
||||
}) as typeof childProcess.spawn)
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { platform } from "os"
|
||||
import { spawn } from "node:child_process"
|
||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||
import {
|
||||
getOsascriptPath,
|
||||
@@ -11,6 +12,21 @@ import {
|
||||
startBackgroundCheck,
|
||||
} from "./session-notification-utils"
|
||||
|
||||
/**
|
||||
* Execute a command using node:child_process instead of Bun shell.
|
||||
* This avoids Bun's ShellInterpreter GC bug on Windows (oven-sh/bun#23177, #24368).
|
||||
*/
|
||||
function execCommand(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: false,
|
||||
})
|
||||
proc.on("close", () => resolve())
|
||||
proc.on("error", () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -65,14 +81,17 @@ async function sendNotification(
|
||||
|
||||
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
|
||||
const script = `display notification "${esMessage}" with title "${esTitle}"`
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(osascriptPath, ["-e", script]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const notifySendPath = await getNotifySendPath()
|
||||
if (!notifySendPath) return
|
||||
|
||||
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(notifySendPath, [title, message]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "win32": {
|
||||
@@ -93,7 +112,8 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
|
||||
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
|
||||
$Notifier.Show($Toast)
|
||||
`.trim().replace(/\n/g, "; ")
|
||||
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -104,17 +124,19 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "darwin": {
|
||||
const afplayPath = await getAfplayPath()
|
||||
if (!afplayPath) return
|
||||
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(afplayPath, [soundPath]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const paplayPath = await getPaplayPath()
|
||||
if (paplayPath) {
|
||||
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(paplayPath, [soundPath]).catch(() => {})
|
||||
} else {
|
||||
const aplayPath = await getAplayPath()
|
||||
if (aplayPath) {
|
||||
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
execCommand(aplayPath, [soundPath]).catch(() => {})
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -122,7 +144,9 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "win32": {
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
const soundScript = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`
|
||||
execCommand(powershellPath, ["-Command", soundScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
35
src/index.ts
35
src/index.ts
@@ -34,10 +34,10 @@ import {
|
||||
} from "./features/context-injector";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
discoverUserClaudeSkillsAsync,
|
||||
discoverProjectClaudeSkillsAsync,
|
||||
discoverOpencodeGlobalSkillsAsync,
|
||||
discoverOpencodeProjectSkillsAsync,
|
||||
discoverUserClaudeSkills,
|
||||
discoverProjectClaudeSkills,
|
||||
discoverOpencodeGlobalSkills,
|
||||
discoverOpencodeProjectSkills,
|
||||
mergeSkills,
|
||||
} from "./features/opencode-skill-loader";
|
||||
import { createBuiltinSkills } from "./features/builtin-skills";
|
||||
@@ -53,6 +53,8 @@ import {
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
createSlashcommandTool,
|
||||
discoverCommandsSync,
|
||||
sessionExists,
|
||||
interactive_bash,
|
||||
startTmuxCheck,
|
||||
@@ -164,10 +166,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const editErrorRecovery = isHookEnabled("edit-error-recovery")
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
@@ -205,10 +203,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
});
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
||||
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
|
||||
includeClaudeSkills ? discoverUserClaudeSkillsAsync() : Promise.resolve([]),
|
||||
discoverOpencodeGlobalSkillsAsync(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkillsAsync() : Promise.resolve([]),
|
||||
discoverOpencodeProjectSkillsAsync(),
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
|
||||
discoverOpencodeProjectSkills(),
|
||||
]);
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
@@ -231,6 +229,16 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
|
||||
const commands = discoverCommandsSync();
|
||||
const slashcommandTool = createSlashcommandTool({
|
||||
commands,
|
||||
skills: mergedSkills,
|
||||
});
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook({ skills: mergedSkills })
|
||||
: null;
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
@@ -251,7 +259,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
look_at: lookAt,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
interactive_bash, // Always included, handles missing tmux gracefully via getCachedTmuxPath() ?? "tmux"
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
|
||||
86
src/mcp/index.test.ts
Normal file
86
src/mcp/index.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createBuiltinMcps } from "./index"
|
||||
|
||||
describe("createBuiltinMcps", () => {
|
||||
test("should return all MCPs when disabled_mcps is empty", () => {
|
||||
//#given
|
||||
const disabledMcps: string[] = []
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should filter out disabled built-in MCPs", () => {
|
||||
//#given
|
||||
const disabledMcps = ["context7"]
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should filter out all built-in MCPs when all disabled", () => {
|
||||
//#given
|
||||
const disabledMcps = ["websearch", "context7", "grep_app"]
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).not.toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).not.toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should ignore custom MCP names in disabled_mcps", () => {
|
||||
//#given
|
||||
const disabledMcps = ["context7", "playwright", "custom"]
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should handle empty disabled_mcps by default", () => {
|
||||
//#given
|
||||
//#when
|
||||
const result = createBuiltinMcps()
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should only filter built-in MCPs, ignoring unknown names", () => {
|
||||
//#given
|
||||
const disabledMcps = ["playwright", "sqlite", "unknown-mcp"]
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { websearch_exa } from "./websearch-exa"
|
||||
import { websearch } from "./websearch"
|
||||
import { context7 } from "./context7"
|
||||
import { grep_app } from "./grep-app"
|
||||
import type { McpName } from "./types"
|
||||
@@ -6,16 +6,16 @@ import type { McpName } from "./types"
|
||||
export { McpNameSchema, type McpName } from "./types"
|
||||
|
||||
const allBuiltinMcps: Record<McpName, { type: "remote"; url: string; enabled: boolean }> = {
|
||||
websearch_exa,
|
||||
websearch,
|
||||
context7,
|
||||
grep_app,
|
||||
}
|
||||
|
||||
export function createBuiltinMcps(disabledMcps: McpName[] = []) {
|
||||
export function createBuiltinMcps(disabledMcps: string[] = []) {
|
||||
const mcps: Record<string, { type: "remote"; url: string; enabled: boolean }> = {}
|
||||
|
||||
for (const [name, config] of Object.entries(allBuiltinMcps)) {
|
||||
if (!disabledMcps.includes(name as McpName)) {
|
||||
if (!disabledMcps.includes(name)) {
|
||||
mcps[name] = config
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const McpNameSchema = z.enum(["websearch_exa", "context7", "grep_app"])
|
||||
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"])
|
||||
|
||||
export type McpName = z.infer<typeof McpNameSchema>
|
||||
|
||||
export const AnyMcpNameSchema = z.string().min(1)
|
||||
|
||||
export type AnyMcpName = z.infer<typeof AnyMcpNameSchema>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const websearch_exa = {
|
||||
export const websearch = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
|
||||
enabled: true,
|
||||
@@ -96,26 +96,18 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
const rawUserAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
// Claude Code agents: Do NOT apply permission migration
|
||||
// Claude Code uses whitelist-based tools format which is semantically different
|
||||
// from OpenCode's denylist-based permission system
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadUserAgents()
|
||||
: {};
|
||||
const rawProjectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadProjectAgents()
|
||||
: {};
|
||||
const rawPluginAgents = pluginComponents.agents;
|
||||
|
||||
const userAgents = Object.fromEntries(
|
||||
Object.entries(rawUserAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
const projectAgents = Object.fromEntries(
|
||||
Object.entries(rawProjectAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
// Plugin agents: Apply permission migration for compatibility
|
||||
const rawPluginAgents = pluginComponents.agents;
|
||||
const pluginAgents = Object.fromEntries(
|
||||
Object.entries(rawPluginAgents).map(([k, v]) => [
|
||||
k,
|
||||
@@ -168,16 +160,17 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
}
|
||||
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, ...planConfigWithoutName } =
|
||||
const { name: _planName, mode: _planMode, ...planConfigWithoutName } =
|
||||
configAgent?.plan ?? {};
|
||||
const migratedPlanConfig = migrateAgentConfig(
|
||||
planConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const plannerSisyphusOverride =
|
||||
pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const defaultModel = config.model as string | undefined;
|
||||
const plannerSisyphusBase = {
|
||||
...migratedPlanConfig,
|
||||
mode: "primary",
|
||||
model: (migratedPlanConfig as Record<string, unknown>).model ?? defaultModel,
|
||||
mode: "primary" as const,
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
@@ -209,7 +202,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan
|
||||
? { disable: true }
|
||||
? { mode: "subagent" as const, hidden: true }
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
@@ -281,24 +274,31 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
};
|
||||
|
||||
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
|
||||
const userCommands = (pluginConfig.claude_code?.commands ?? true)
|
||||
? loadUserCommands()
|
||||
: {};
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = (config.command as Record<string, unknown>) ?? {};
|
||||
const projectCommands = (pluginConfig.claude_code?.commands ?? true)
|
||||
? loadProjectCommands()
|
||||
: {};
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
|
||||
const userSkills = (pluginConfig.claude_code?.skills ?? true)
|
||||
? loadUserSkills()
|
||||
: {};
|
||||
const projectSkills = (pluginConfig.claude_code?.skills ?? true)
|
||||
? loadProjectSkills()
|
||||
: {};
|
||||
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
|
||||
const opencodeProjectSkills = loadOpencodeProjectSkills();
|
||||
// Parallel loading of all commands and skills for faster startup
|
||||
const includeClaudeCommands = pluginConfig.claude_code?.commands ?? true;
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills ?? true;
|
||||
|
||||
const [
|
||||
userCommands,
|
||||
projectCommands,
|
||||
opencodeGlobalCommands,
|
||||
opencodeProjectCommands,
|
||||
userSkills,
|
||||
projectSkills,
|
||||
opencodeGlobalSkills,
|
||||
opencodeProjectSkills,
|
||||
] = await Promise.all([
|
||||
includeClaudeCommands ? loadUserCommands() : Promise.resolve({}),
|
||||
includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}),
|
||||
loadOpencodeGlobalCommands(),
|
||||
loadOpencodeProjectCommands(),
|
||||
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
|
||||
includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}),
|
||||
loadOpencodeGlobalSkills(),
|
||||
loadOpencodeProjectSkills(),
|
||||
]);
|
||||
|
||||
config.command = {
|
||||
...builtinCommands,
|
||||
|
||||
@@ -5,16 +5,17 @@ import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
|
||||
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
|
||||
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir()
|
||||
}
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
if (customZshPath && existsSync(customZshPath)) {
|
||||
return customZshPath
|
||||
function findShellPath(defaultPaths: string[], customPath?: string): string | null {
|
||||
if (customPath && existsSync(customPath)) {
|
||||
return customPath
|
||||
}
|
||||
for (const path of DEFAULT_ZSH_PATHS) {
|
||||
for (const path of defaultPaths) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
@@ -22,6 +23,14 @@ function findZshPath(customZshPath?: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
|
||||
}
|
||||
|
||||
function findBashPath(): string | null {
|
||||
return findShellPath(DEFAULT_BASH_PATHS)
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface CommandResult {
|
||||
@@ -55,10 +64,18 @@ export async function executeHookCommand(
|
||||
let finalCommand = expandedCommand
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = options.zshPath || findZshPath()
|
||||
// Always verify shell exists before using it
|
||||
const zshPath = findZshPath(options.zshPath)
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
if (zshPath) {
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
} else {
|
||||
// Fall back to bash login shell to preserve PATH from user profile
|
||||
const bashPath = findBashPath()
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
// If neither zsh nor bash found, fall through to spawn with shell: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lstatSync, readlinkSync } from "fs"
|
||||
import { promises as fs } from "fs"
|
||||
import { resolve } from "path"
|
||||
|
||||
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
|
||||
@@ -24,3 +25,16 @@ export function resolveSymlink(filePath: string): string {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
|
||||
try {
|
||||
const stats = await fs.lstat(filePath)
|
||||
if (stats.isSymbolicLink()) {
|
||||
const linkTarget = await fs.readlink(filePath)
|
||||
return resolve(filePath, "..", linkTarget)
|
||||
}
|
||||
return filePath
|
||||
} catch {
|
||||
return filePath
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
|
||||
import { grep } from "./grep"
|
||||
import { glob } from "./glob"
|
||||
import { slashcommand } from "./slashcommand"
|
||||
export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand"
|
||||
|
||||
import {
|
||||
session_list,
|
||||
@@ -73,7 +73,6 @@ export const builtinTools: Record<string, ToolDefinition> = {
|
||||
ast_grep_replace,
|
||||
grep,
|
||||
glob,
|
||||
slashcommand,
|
||||
session_list,
|
||||
session_read,
|
||||
session_search,
|
||||
|
||||
@@ -129,22 +129,38 @@ async function formatMcpCapabilities(
|
||||
}
|
||||
|
||||
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
|
||||
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
|
||||
const skillInfos = skills.map(loadedSkillToInfo)
|
||||
let cachedSkills: LoadedSkill[] | null = null
|
||||
let cachedDescription: string | null = null
|
||||
|
||||
const description = skillInfos.length === 0
|
||||
? TOOL_DESCRIPTION_NO_SKILLS
|
||||
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||
if (options.skills) return options.skills
|
||||
if (cachedSkills) return cachedSkills
|
||||
cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
const getDescription = async (): Promise<string> => {
|
||||
if (cachedDescription) return cachedDescription
|
||||
const skills = await getSkills()
|
||||
const skillInfos = skills.map(loadedSkillToInfo)
|
||||
cachedDescription = skillInfos.length === 0
|
||||
? TOOL_DESCRIPTION_NO_SKILLS
|
||||
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
|
||||
return cachedDescription
|
||||
}
|
||||
|
||||
getDescription()
|
||||
|
||||
return tool({
|
||||
description,
|
||||
get description() {
|
||||
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
||||
},
|
||||
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)
|
||||
const skills = await getSkills()
|
||||
const skill = skills.find(s => s.name === args.name)
|
||||
|
||||
if (!skill) {
|
||||
const available = skills.map(s => s.name).join(", ")
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export * from "./types"
|
||||
export { slashcommand } from "./tools"
|
||||
export { slashcommand, createSlashcommandTool, discoverCommandsSync } from "./tools"
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load
|
||||
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"
|
||||
import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types"
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
@@ -51,7 +51,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
|
||||
return commands
|
||||
}
|
||||
|
||||
function discoverCommandsSync(): CommandInfo[] {
|
||||
export function discoverCommandsSync(): CommandInfo[] {
|
||||
const { homedir } = require("os")
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
@@ -80,22 +80,10 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: skill.scope,
|
||||
lazyContentLoader: skill.lazyContent,
|
||||
}
|
||||
}
|
||||
|
||||
const availableCommands = discoverCommandsSync()
|
||||
const availableSkills = discoverAllSkills()
|
||||
const availableItems = [
|
||||
...availableCommands,
|
||||
...availableSkills.map(skillToCommandInfo),
|
||||
]
|
||||
const commandListForDescription = availableItems
|
||||
.map((cmd) => {
|
||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
|
||||
const sections: string[] = []
|
||||
|
||||
@@ -125,8 +113,13 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
let content = cmd.content || ""
|
||||
if (!content && cmd.lazyContentLoader) {
|
||||
content = await cmd.lazyContentLoader.load()
|
||||
}
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
@@ -151,62 +144,109 @@ function formatCommandList(items: CommandInfo[]): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export const slashcommand: ToolDefinition = tool({
|
||||
description: `Load a skill to get detailed instructions for a specific task.
|
||||
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.
|
||||
`
|
||||
|
||||
function buildDescriptionFromItems(items: CommandInfo[]): string {
|
||||
const commandListForDescription = items
|
||||
.map((cmd) => {
|
||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
|
||||
})
|
||||
.join("\n")
|
||||
|
||||
return `${TOOL_DESCRIPTION_PREFIX}
|
||||
<available_skills>
|
||||
${commandListForDescription}
|
||||
</available_skills>`,
|
||||
</available_skills>`
|
||||
}
|
||||
|
||||
args: {
|
||||
command: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
|
||||
),
|
||||
},
|
||||
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
|
||||
let cachedCommands: CommandInfo[] | null = options.commands ?? null
|
||||
let cachedSkills: LoadedSkill[] | null = options.skills ?? null
|
||||
let cachedDescription: string | null = null
|
||||
|
||||
async execute(args) {
|
||||
const commands = discoverCommandsSync()
|
||||
const skills = discoverAllSkills()
|
||||
const allItems = [
|
||||
...commands,
|
||||
...skills.map(skillToCommandInfo),
|
||||
]
|
||||
const getCommands = (): CommandInfo[] => {
|
||||
if (cachedCommands) return cachedCommands
|
||||
cachedCommands = discoverCommandsSync()
|
||||
return cachedCommands
|
||||
}
|
||||
|
||||
if (!args.command) {
|
||||
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
|
||||
}
|
||||
const getSkills = async (): Promise<LoadedSkill[]> => {
|
||||
if (cachedSkills) return cachedSkills
|
||||
cachedSkills = await discoverAllSkills()
|
||||
return cachedSkills
|
||||
}
|
||||
|
||||
const cmdName = args.command.replace(/^\//, "")
|
||||
const getAllItems = async (): Promise<CommandInfo[]> => {
|
||||
const commands = getCommands()
|
||||
const skills = await getSkills()
|
||||
return [...commands, ...skills.map(skillToCommandInfo)]
|
||||
}
|
||||
|
||||
const exactMatch = allItems.find(
|
||||
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
|
||||
)
|
||||
const buildDescription = async (): Promise<string> => {
|
||||
if (cachedDescription) return cachedDescription
|
||||
const allItems = await getAllItems()
|
||||
cachedDescription = buildDescriptionFromItems(allItems)
|
||||
return cachedDescription
|
||||
}
|
||||
|
||||
if (exactMatch) {
|
||||
return await formatLoadedCommand(exactMatch)
|
||||
}
|
||||
// Pre-warm the cache immediately
|
||||
buildDescription()
|
||||
|
||||
const partialMatches = allItems.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
|
||||
)
|
||||
return tool({
|
||||
get description() {
|
||||
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
|
||||
},
|
||||
|
||||
if (partialMatches.length > 0) {
|
||||
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
|
||||
return (
|
||||
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
|
||||
formatCommandList(allItems)
|
||||
args: {
|
||||
command: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
|
||||
),
|
||||
},
|
||||
|
||||
async execute(args) {
|
||||
const allItems = await getAllItems()
|
||||
|
||||
if (!args.command) {
|
||||
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
|
||||
}
|
||||
|
||||
const cmdName = args.command.replace(/^\//, "")
|
||||
|
||||
const exactMatch = allItems.find(
|
||||
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
`Command or skill "/${cmdName}" not found.\n\n` +
|
||||
formatCommandList(allItems) +
|
||||
"\n\nTry a different name."
|
||||
)
|
||||
},
|
||||
})
|
||||
if (exactMatch) {
|
||||
return await formatLoadedCommand(exactMatch)
|
||||
}
|
||||
|
||||
const partialMatches = allItems.filter((cmd) =>
|
||||
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
|
||||
)
|
||||
|
||||
if (partialMatches.length > 0) {
|
||||
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
|
||||
return (
|
||||
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
|
||||
formatCommandList(allItems)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
`Command or skill "/${cmdName}" not found.\n\n` +
|
||||
formatCommandList(allItems) +
|
||||
"\n\nTry a different name."
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Default instance for backward compatibility (lazy loading)
|
||||
export const slashcommand = createSlashcommandTool()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
|
||||
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface CommandMetadata {
|
||||
@@ -15,4 +17,12 @@ export interface CommandInfo {
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
export interface SlashcommandToolOptions {
|
||||
/** Pre-loaded commands (skip discovery if provided) */
|
||||
commands?: CommandInfo[]
|
||||
/** Pre-loaded skills (skip discovery if provided) */
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user