Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14ff86c547 | ||
|
|
e4036185f0 | ||
|
|
d34154bc68 | ||
|
|
9e00be91af | ||
|
|
40d4673201 | ||
|
|
cf33fc5da1 | ||
|
|
407786978a | ||
|
|
15454f1d81 | ||
|
|
56160d17f8 | ||
|
|
61bbbcb577 | ||
|
|
adabace02d | ||
|
|
41f93c9f8b | ||
|
|
8102d178cb | ||
|
|
4f019f8fe5 | ||
|
|
7b19177c8a | ||
|
|
e8f59cbbf8 | ||
|
|
2d23a81926 | ||
|
|
31cb8616c2 | ||
|
|
1932257f82 | ||
|
|
5a793bb526 | ||
|
|
2ec351d0d8 | ||
|
|
441fc1a219 | ||
|
|
bd67419d1d | ||
|
|
dca98121ac | ||
|
|
3fcfedcec0 | ||
|
|
530c4d63d5 | ||
|
|
e0b43380cc | ||
|
|
a27cac96d5 | ||
|
|
fef7f4ca03 | ||
|
|
e147be7ed4 | ||
|
|
124c3b3e8f | ||
|
|
5678e0bac6 | ||
|
|
207435450c | ||
|
|
376bf363af | ||
|
|
c7a65af475 | ||
|
|
8e7447deee | ||
|
|
15a748b817 | ||
|
|
c0e0dc1f95 | ||
|
|
7059407cbc | ||
|
|
589cf60252 | ||
|
|
e5cdaa5192 | ||
|
|
340eb30147 | ||
|
|
e72b927ccb | ||
|
|
3c6ffe5d9c | ||
|
|
938a3709e1 | ||
|
|
47f218e33f | ||
|
|
e07a25baa4 | ||
|
|
08ede0a28d |
98
README.ko.md
98
README.ko.md
@@ -15,6 +15,7 @@
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [Claude Code 호환성](#claude-code-호환성)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [설정](#설정)
|
||||
- [작성자의 노트](#작성자의-노트)
|
||||
@@ -105,7 +106,19 @@ opencode --version # 1.0.132 이상이어야 함
|
||||
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
|
||||
```
|
||||
|
||||
> **참고**: 모델 인증이 필요하면, `opencode` 실행 후 `/connect` 명령어를 사용하거나, tmux를 통해 `opencode auth login`을 안내하세요.
|
||||
### 4단계: 인증정보 설정
|
||||
|
||||
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 다음의 내용을 진행주세요.
|
||||
|
||||
1. tmux 를 사용하여 셸 열기
|
||||
2. opencode auth login > Provider Anthropic > Login method Claude Pro/Max > 사용자에게 oauth 화면에서 인증하라고 안내
|
||||
3. 사용자에게 macOS 일 경우 VibeProxy, 아닐 경우 CLIProxyAPI 를 사용하여 OpenAI / Google 인증을 완료하라고 안내
|
||||
|
||||
### ⚠️ 주의
|
||||
|
||||
**사용자가 명시적으로 요청하지 않는 한, `oh-my-opencode.json` 설정 파일을 생성하거나 수정하지 마세요.**
|
||||
|
||||
플러그인은 설정 파일 없이도 기본값으로 완벽하게 동작합니다. 설정 파일은 사용자가 커스터마이징을 원할 때만 필요합니다.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -153,6 +166,11 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
- **Directory README.md Injector**: 파일을 읽을 때 `README.md` 내용을 자동으로 주입합니다. AGENTS.md Injector와 동일하게 동작하며, 파일 디렉토리부터 프로젝트 루트까지 탐색합니다. LLM 에이전트에게 프로젝트 문서 컨텍스트를 제공합니다. 각 디렉토리의 README는 세션당 한 번만 주입됩니다.
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
|
||||
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
|
||||
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
|
||||
|
||||
### Agents
|
||||
|
||||
@@ -220,8 +238,72 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code 호환성
|
||||
|
||||
Oh My OpenCode는 Claude Code 설정과 완벽하게 호환됩니다. Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
|
||||
|
||||
#### Hooks 통합
|
||||
|
||||
Claude Code의 `settings.json` 훅 시스템을 통해 커스텀 스크립트를 실행합니다. Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
|
||||
|
||||
- `~/.claude/settings.json` (사용자)
|
||||
- `./.claude/settings.json` (프로젝트)
|
||||
- `./.claude/settings.local.json` (로컬, git-ignored)
|
||||
|
||||
지원되는 훅 이벤트:
|
||||
- **PreToolUse**: 도구 실행 전에 실행. 차단하거나 도구 입력을 수정할 수 있습니다.
|
||||
- **PostToolUse**: 도구 실행 후에 실행. 경고나 컨텍스트를 추가할 수 있습니다.
|
||||
- **UserPromptSubmit**: 사용자가 프롬프트를 제출할 때 실행. 차단하거나 메시지를 주입할 수 있습니다.
|
||||
- **Stop**: 세션이 유휴 상태가 될 때 실행. 후속 프롬프트를 주입할 수 있습니다.
|
||||
|
||||
`settings.json` 예시:
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 설정 로더
|
||||
|
||||
**Command Loader**: 4개 디렉토리에서 마크다운 기반 슬래시 명령어를 로드합니다:
|
||||
- `~/.claude/commands/` (사용자)
|
||||
- `./.claude/commands/` (프로젝트)
|
||||
- `~/.config/opencode/command/` (opencode 전역)
|
||||
- `./.opencode/command/` (opencode 프로젝트)
|
||||
|
||||
**Skill Loader**: `SKILL.md`가 있는 디렉토리 기반 스킬을 로드합니다:
|
||||
- `~/.claude/skills/` (사용자)
|
||||
- `./.claude/skills/` (프로젝트)
|
||||
|
||||
**Agent Loader**: 마크다운 파일에서 커스텀 에이전트 정의를 로드합니다:
|
||||
- `~/.claude/agents/*.md` (사용자)
|
||||
- `./.claude/agents/*.md` (프로젝트)
|
||||
|
||||
**MCP Loader**: `.mcp.json` 파일에서 MCP 서버 설정을 로드합니다:
|
||||
- `~/.claude/.mcp.json` (사용자)
|
||||
- `./.mcp.json` (프로젝트)
|
||||
- `./.claude/.mcp.json` (로컬)
|
||||
- 환경변수 확장 지원 (`${VAR}` 문법)
|
||||
|
||||
#### 데이터 저장소
|
||||
|
||||
**Todo 관리**: 세션 todo가 `~/.claude/todos/`에 Claude Code 호환 형식으로 저장됩니다.
|
||||
|
||||
**Transcript**: 세션 활동이 `~/.claude/transcripts/`에 JSONL 형식으로 기록되어 재생 및 분석이 가능합니다.
|
||||
|
||||
> **`claude-code-*` 네이밍에 대해**: `src/features/claude-code-*/` 아래의 기능들은 Claude Code의 설정 시스템에서 마이그레이션되었습니다. 이 네이밍 규칙은 어떤 기능이 Claude Code에서 유래했는지 명확히 식별합니다.
|
||||
|
||||
### 기타 편의 기능
|
||||
|
||||
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
|
||||
- **Session State**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
|
||||
|
||||
## 설정
|
||||
|
||||
@@ -257,7 +339,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
또는 `disabled_agents`로 비활성화할 수 있습니다:
|
||||
또는 ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_agents` 를 사용하여 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -269,7 +351,9 @@ Schema 자동 완성이 지원됩니다:
|
||||
|
||||
### MCPs
|
||||
|
||||
내장된 MCP를 비활성화합니다:
|
||||
기본적으로 Context7, Exa MCP 를 지원합니다.
|
||||
|
||||
이것이 마음에 들지 않는다면, ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_mcps` 를 사용하여 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -277,13 +361,13 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
더 자세한 내용은 [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers)를 참조하세요.
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액션)만을 위한 것**입니다. 분석용 LSP는 OpenCode 자체에서 처리합니다.
|
||||
OpenCode 는 분석을 위해 LSP 도구를 제공합니다.
|
||||
Oh My OpenCode 에서는 LSP 의 리팩토링(이름 변경, 코드 액션) 도구를 제공합니다.
|
||||
OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.json 에 설정 된 것) 을 그대로 지원하고, Oh My OpenCode 만을 위한 추가적인 설정도 아래와 같이 설정 할 수 있습니다.
|
||||
|
||||
`lsp` 옵션을 통해 LSP 서버를 설정합니다:
|
||||
~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `lsp` 옵션을 통해 LSP 서버를 추가로 설정 할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
112
README.md
112
README.md
@@ -15,6 +15,7 @@ English | [한국어](README.ko.md)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Grep](#grep)
|
||||
- [Built-in MCPs](#built-in-mcps)
|
||||
- [Claude Code Compatibility](#claude-code-compatibility)
|
||||
- [Other Features](#other-features)
|
||||
- [Configuration](#configuration)
|
||||
- [Author's Note](#authors-note)
|
||||
@@ -27,11 +28,12 @@ Oh My OpenCode
|
||||
oMoMoMoMoMo···
|
||||
|
||||
If you work in tech, you likely appreciated [Claude Code](https://www.claude.com/product/claude-code).
|
||||
If you are a hacker, you will fucking falling in love with [OpenCode](https://github.com/sst/opencode).
|
||||
If you are a hacker, you will fucking fall in love with [OpenCode](https://github.com/sst/opencode).
|
||||
|
||||
You don't write code just for a paycheck? You write because you genuinely love it?
|
||||
|
||||
To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not you? It's still worth the investment. Give it 10 minutes. Your work and life will improve. I promise.
|
||||
Do you remember the first time you switched from Windows to Linux? The excitement of configuring everything just the way you wanted?
|
||||
In an era where that romance feels lost, OpenCode will be that project for you.
|
||||
If you love coding and computers, OpenCode will feel exactly like that moment you first touched Linux after years of Windows.
|
||||
Even if you don't, invest a little time. Boost your skills and productivity by multipliers.
|
||||
|
||||
## TL;DR
|
||||
|
||||
@@ -41,7 +43,7 @@ To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not yo
|
||||
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
|
||||
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
|
||||
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
|
||||
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
|
||||
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It just installs.**
|
||||
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
|
||||
|
||||
## Installation
|
||||
@@ -103,7 +105,19 @@ opencode --version # Should be >= 1.0.132
|
||||
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
|
||||
```
|
||||
|
||||
> **Note**: If model authentication is needed, run `opencode` and use `/connect` command, or assist the user with `opencode auth login` via tmux.
|
||||
### Step 4: Configure Authentication
|
||||
|
||||
Once `oh-my-opencode` is added to the plugin list, use `tmux` to proceed with the following:
|
||||
|
||||
1. Open a shell using `tmux`.
|
||||
2. Run `opencode auth login` > Select Provider `Anthropic` > Select Login method `Claude Pro/Max` > Instruct the user to authenticate via the OAuth screen.
|
||||
3. Recommend the setup: [VibeProxy](https://github.com/automazeio/vibeproxy) for macOS users, or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) for others.
|
||||
|
||||
### ⚠️ Warning
|
||||
|
||||
**Do NOT create or modify the `oh-my-opencode.json` configuration file unless the user explicitly requests it.**
|
||||
|
||||
The plugin works perfectly with default settings without any configuration file. The configuration file is only needed when the user wants customization.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -114,7 +128,7 @@ OpenCode is limitlessly extensible and customizable. Zero screen flicker.
|
||||
You can mix and orchestrate models to your exact specifications.
|
||||
It is feature-rich. It is elegant. It handles the terminal without hesitation. It is high-performance.
|
||||
|
||||
But here is the catch: the learning curve is steep. There is a lot to master.
|
||||
But here is the catch: the learning curve is steep. There is a lot to master. And your time is expensive.
|
||||
|
||||
Inspired by [AmpCode](https://ampcode.com) and [Claude Code](https://code.claude.com/docs/en/overview), I have implemented their features here—often with superior execution.
|
||||
Because this is OpenCode.
|
||||
@@ -149,6 +163,11 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
|
||||
```
|
||||
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
|
||||
- **Directory README.md Injector**: Automatically injects `README.md` contents when reading files. Works identically to the AGENTS.md Injector, searching upward from the file's directory to project root. Provides project documentation context to the LLM agent. Each directory's README is injected only once per session.
|
||||
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
|
||||
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
|
||||
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
|
||||
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
|
||||
|
||||
### Agents
|
||||
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
@@ -217,9 +236,72 @@ Don't need these? Disable them via `oh-my-opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
### Claude Code Compatibility
|
||||
|
||||
Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.
|
||||
|
||||
#### Hooks Integration
|
||||
|
||||
Execute custom scripts via Claude Code's `settings.json` hook system. Oh My OpenCode reads and executes hooks defined in:
|
||||
|
||||
- `~/.claude/settings.json` (user)
|
||||
- `./.claude/settings.json` (project)
|
||||
- `./.claude/settings.local.json` (local, git-ignored)
|
||||
|
||||
Supported hook events:
|
||||
- **PreToolUse**: Runs before tool execution. Can block or modify tool input.
|
||||
- **PostToolUse**: Runs after tool execution. Can add warnings or context.
|
||||
- **UserPromptSubmit**: Runs when user submits a prompt. Can block or inject messages.
|
||||
- **Stop**: Runs when session goes idle. Can inject follow-up prompts.
|
||||
|
||||
Example `settings.json`:
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Configuration Loaders
|
||||
|
||||
**Command Loader**: Loads markdown-based slash commands from 4 directories:
|
||||
- `~/.claude/commands/` (user)
|
||||
- `./.claude/commands/` (project)
|
||||
- `~/.config/opencode/command/` (opencode global)
|
||||
- `./.opencode/command/` (opencode project)
|
||||
|
||||
**Skill Loader**: Loads directory-based skills with `SKILL.md`:
|
||||
- `~/.claude/skills/` (user)
|
||||
- `./.claude/skills/` (project)
|
||||
|
||||
**Agent Loader**: Loads custom agent definitions from markdown files:
|
||||
- `~/.claude/agents/*.md` (user)
|
||||
- `./.claude/agents/*.md` (project)
|
||||
|
||||
**MCP Loader**: Loads MCP server configurations from `.mcp.json` files:
|
||||
- `~/.claude/.mcp.json` (user)
|
||||
- `./.mcp.json` (project)
|
||||
- `./.claude/.mcp.json` (local)
|
||||
- Supports environment variable expansion (`${VAR}` syntax)
|
||||
|
||||
#### Data Storage
|
||||
|
||||
**Todo Management**: Session todos are stored in Claude Code compatible format at `~/.claude/todos/`.
|
||||
|
||||
**Transcript**: Session activity is logged to `~/.claude/transcripts/` in JSONL format, enabling replay and analysis.
|
||||
|
||||
> **Note on `claude-code-*` naming**: Features under `src/features/claude-code-*/` are migrated from Claude Code's configuration system. This naming convention clearly identifies which features originated from Claude Code.
|
||||
|
||||
### Other Features
|
||||
|
||||
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
|
||||
- **Session State**: Centralized session tracking module used by event hooks and terminal title updates.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -255,7 +337,7 @@ Override built-in agent settings:
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
Or disable agents via `disabled_agents`:
|
||||
Or you can disable them using `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -267,7 +349,9 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
|
||||
|
||||
### MCPs
|
||||
|
||||
Disable built-in MCPs:
|
||||
By default, Context7 and Exa MCP are supported.
|
||||
|
||||
If you don't want these, you can disable them using `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -275,13 +359,13 @@ Disable built-in MCPs:
|
||||
}
|
||||
```
|
||||
|
||||
See [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers) for more.
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode's LSP tools are for **refactoring only** (rename, code actions). Analysis LSP is handled by OpenCode itself.
|
||||
OpenCode provides LSP tools for analysis.
|
||||
Oh My OpenCode provides LSP tools for refactoring (rename, code actions).
|
||||
It supports all LSP configurations and custom settings supported by OpenCode (those configured in opencode.json), and you can also configure additional settings specifically for Oh My OpenCode as shown below.
|
||||
|
||||
Configure LSP servers via `lsp` option:
|
||||
You can configure additional LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -321,7 +405,7 @@ If this sounds arrogant and you have a superior solution, send a PR. You are wel
|
||||
|
||||
As of now, I have no affiliation with any of the projects or models mentioned here. This plugin is purely based on personal experimentation and preference.
|
||||
|
||||
I constructed 99% of this project using OpenCode. I focused on functional verification. This documentation has been personally reviewed and comprehensively rewritten, so you can rely on it with confidence.
|
||||
I constructed 99% of this project using OpenCode. I focused on functional verification, and honestly, I don't know how to write proper TypeScript. **But I personally reviewed and comprehensively rewritten this documentation, so you can rely on it with confidence.**
|
||||
## Warnings
|
||||
|
||||
- If you are on [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) or lower, OpenCode has a bug that might break config.
|
||||
|
||||
9
bun.lock
9
bun.lock
@@ -7,13 +7,14 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -63,7 +64,7 @@
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.4", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-vsbdLMQYJJNDV/baTDnNqqg/MZwA+9nz7TE6Mybj8zjZVTCn4ZivH4hAdD5p4fLxhGZEJ5x1UDmXA6pAGA7lHA=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
|
||||
@@ -99,6 +100,8 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
@@ -106,5 +109,7 @@
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
# Comment-Checker TypeScript Port 구현 계획
|
||||
|
||||
## 1. 아키텍처 개요
|
||||
|
||||
### 1.1 핵심 도전 과제
|
||||
|
||||
**OpenCode Hook의 제약사항:**
|
||||
- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능
|
||||
- `tool.execute.after`: `tool_input`이 **제공되지 않음** (Claude Code와의 핵심 차이점)
|
||||
- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회
|
||||
|
||||
### 1.2 디렉토리 구조
|
||||
|
||||
```
|
||||
src/hooks/comment-checker/
|
||||
├── index.ts # Hook factory, 메인 엔트리포인트
|
||||
├── types.ts # 모든 타입 정의
|
||||
├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록
|
||||
├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지
|
||||
├── filters/
|
||||
│ ├── index.ts # 필터 barrel export
|
||||
│ ├── bdd.ts # BDD 패턴 필터
|
||||
│ ├── directive.ts # 린터/타입체커 디렉티브 필터
|
||||
│ ├── docstring.ts # 독스트링 필터
|
||||
│ └── shebang.ts # Shebang 필터
|
||||
├── output/
|
||||
│ ├── index.ts # 출력 barrel export
|
||||
│ ├── formatter.ts # FormatHookMessage
|
||||
│ └── xml-builder.ts # BuildCommentsXML
|
||||
└── utils.ts # 유틸리티 함수
|
||||
```
|
||||
|
||||
### 1.3 데이터 흐름
|
||||
|
||||
```
|
||||
[write/edit 도구 실행]
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ tool.execute.before │
|
||||
│ - 파일 경로 캡처 │
|
||||
│ - pendingCalls Map │
|
||||
│ 에 저장 │
|
||||
└──────────┬───────────┘
|
||||
│
|
||||
▼
|
||||
[도구 실제 실행]
|
||||
│
|
||||
▼
|
||||
┌──────────────────────┐
|
||||
│ tool.execute.after │
|
||||
│ - pendingCalls에서 │
|
||||
│ 데이터 조회 │
|
||||
│ - 파일 읽기 │
|
||||
│ - 코멘트 감지 │
|
||||
│ - 필터 적용 │
|
||||
│ - 메시지 주입 │
|
||||
└──────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 구현 순서
|
||||
|
||||
### Phase 1: 기반 구조
|
||||
1. `src/hooks/comment-checker/` 디렉토리 생성
|
||||
2. `types.ts` - 모든 타입 정의
|
||||
3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴
|
||||
|
||||
### Phase 2: 필터 구현
|
||||
4. `filters/bdd.ts` - BDD 패턴 필터
|
||||
5. `filters/directive.ts` - 디렉티브 필터
|
||||
6. `filters/docstring.ts` - 독스트링 필터
|
||||
7. `filters/shebang.ts` - Shebang 필터
|
||||
8. `filters/index.ts` - 필터 조합
|
||||
|
||||
### Phase 3: 코어 로직
|
||||
9. `detector.ts` - web-tree-sitter 기반 코멘트 감지
|
||||
10. `output/xml-builder.ts` - XML 출력
|
||||
11. `output/formatter.ts` - 메시지 포매팅
|
||||
|
||||
### Phase 4: Hook 통합
|
||||
12. `index.ts` - Hook factory 및 상태 관리
|
||||
13. `src/hooks/index.ts` 업데이트 - export 추가
|
||||
|
||||
### Phase 5: 의존성 및 빌드
|
||||
14. `package.json` 업데이트 - web-tree-sitter 추가
|
||||
15. typecheck 및 build 검증
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 구현 사항
|
||||
|
||||
### 3.1 언어 레지스트리 (38개 언어)
|
||||
|
||||
```typescript
|
||||
const LANGUAGE_REGISTRY: Record<string, LanguageConfig> = {
|
||||
python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." },
|
||||
javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" },
|
||||
typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" },
|
||||
tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" },
|
||||
go: { extensions: [".go"], commentQuery: "(comment) @comment" },
|
||||
rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" },
|
||||
// ... 38개 전체
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 필터 로직
|
||||
|
||||
**BDD 필터**: `given, when, then, arrange, act, assert`
|
||||
**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+
|
||||
**Docstring 필터**: `IsDocstring || starts with /**`
|
||||
**Shebang 필터**: `starts with #!`
|
||||
|
||||
### 3.3 출력 형식 (Go 버전과 100% 동일)
|
||||
|
||||
```
|
||||
COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
|
||||
|
||||
Your recent changes contain comments or docstrings, which triggered this hook.
|
||||
You need to take immediate action. You must follow the conditions below.
|
||||
(Listed in priority order - you must always act according to this priority order)
|
||||
|
||||
CRITICAL WARNING: This hook message MUST NEVER be ignored...
|
||||
|
||||
<comments file="/path/to/file.py">
|
||||
<comment line-number="10">// comment text</comment>
|
||||
</comments>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 생성할 파일 목록
|
||||
|
||||
1. `src/hooks/comment-checker/types.ts`
|
||||
2. `src/hooks/comment-checker/constants.ts`
|
||||
3. `src/hooks/comment-checker/filters/bdd.ts`
|
||||
4. `src/hooks/comment-checker/filters/directive.ts`
|
||||
5. `src/hooks/comment-checker/filters/docstring.ts`
|
||||
6. `src/hooks/comment-checker/filters/shebang.ts`
|
||||
7. `src/hooks/comment-checker/filters/index.ts`
|
||||
8. `src/hooks/comment-checker/output/xml-builder.ts`
|
||||
9. `src/hooks/comment-checker/output/formatter.ts`
|
||||
10. `src/hooks/comment-checker/output/index.ts`
|
||||
11. `src/hooks/comment-checker/detector.ts`
|
||||
12. `src/hooks/comment-checker/index.ts`
|
||||
|
||||
## 5. 수정할 파일 목록
|
||||
|
||||
1. `src/hooks/index.ts` - export 추가
|
||||
2. `package.json` - web-tree-sitter 의존성
|
||||
|
||||
---
|
||||
|
||||
## 6. Definition of Done
|
||||
|
||||
- [ ] write/edit 도구 실행 시 코멘트 감지 동작
|
||||
- [ ] 4개 필터 모두 정상 작동
|
||||
- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go)
|
||||
- [ ] Go 버전과 동일한 출력 형식
|
||||
- [ ] typecheck 통과
|
||||
- [ ] build 성공
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd /Users/yeongyu/local-workspaces/oh-my-opencode
|
||||
|
||||
echo "=== Pushing to origin ==="
|
||||
git push -f origin master
|
||||
|
||||
echo "=== Triggering workflow ==="
|
||||
gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1
|
||||
|
||||
echo "=== Done! ==="
|
||||
echo "Usage: ./local-ignore/push-and-release.sh 0.1.6"
|
||||
143
notepad.md
143
notepad.md
@@ -1,143 +0,0 @@
|
||||
# MCP Loader Plugin - Orchestration Notepad
|
||||
|
||||
## Task Started
|
||||
All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||
|
||||
---
|
||||
|
||||
## Orchestration Overview
|
||||
|
||||
**Todo List File**: ./tool-search-tool-plan.md
|
||||
**Total Tasks**: 5 (Phase 1-5)
|
||||
**Target Files**:
|
||||
- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin
|
||||
- `~/.config/opencode/mcp-loader.json` - Global config example
|
||||
- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests
|
||||
|
||||
---
|
||||
|
||||
## Accumulated Wisdom
|
||||
|
||||
(To be populated by executors)
|
||||
|
||||
---
|
||||
|
||||
## Task Progress
|
||||
|
||||
| Task | Description | Status |
|
||||
|------|-------------|--------|
|
||||
| 1 | Plugin skeleton + config loader | pending |
|
||||
| 2 | MCP server registry + lifecycle | pending |
|
||||
| 3 | mcp_search + mcp_status tools | pending |
|
||||
| 4 | mcp_call tool | pending |
|
||||
| 5 | Documentation | pending |
|
||||
|
||||
---
|
||||
|
||||
|
||||
## 2025-12-04 16:58 - Task 1 Completed
|
||||
|
||||
### Summary
|
||||
- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader
|
||||
- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests
|
||||
|
||||
### Key Implementation Details
|
||||
- Config merge: project overrides global for same server names, merges different
|
||||
- Env var substitution: `{env:VAR}` → `process.env.VAR`
|
||||
- Validation: type required, local needs command, remote needs url
|
||||
- Empty config returns `{ servers: {} }` (not error)
|
||||
|
||||
### Test Results
|
||||
- 14 tests passed
|
||||
- substituteEnvVars: 4 tests
|
||||
- substituteHeaderEnvVars: 1 test
|
||||
- loadConfig: 9 tests
|
||||
|
||||
### Files Created
|
||||
- `~/.config/opencode/plugin/mcp-loader.ts`
|
||||
- `~/.config/opencode/plugin/mcp-loader.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 18:56] - Task 1: Remove unused import formatWorkspaceEdit from LSP tools
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - simple import cleanup task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed only `formatWorkspaceEdit` from import list at line 17
|
||||
- Kept all other imports intact (formatCodeActions, applyWorkspaceEdit, formatApplyResult remain)
|
||||
- Verified the function exists in utils.ts:212 but is truly unused in tools.ts
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified for remaining tasks
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "formatWorkspaceEdit" src/tools/lsp/tools.ts` → no matches (confirmed removal)
|
||||
|
||||
### LEARNINGS
|
||||
- Convention: This project uses `bun run typecheck` (tsc --noEmit) and `bun run build` for verification
|
||||
- The `formatWorkspaceEdit` function still exists in utils.ts - it's exported but just not used in tools.ts
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 19:00] - Task 2: Remove unused ThinkingPart interface and fallbackRevertStrategy function
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - both items were genuinely unused (no callers found)
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed `ThinkingPart` interface (lines 37-40) - defined but never referenced
|
||||
- Removed `fallbackRevertStrategy` function (lines 189-244) - defined but never called
|
||||
- Added comment explaining removal reason as per task requirements
|
||||
- Kept `ThinkingPartType`, `prependThinkingPart`, `stripThinkingParts` - these are different items and ARE used
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "ThinkingPart" src/hooks/session-recovery/` → only related types/functions found, interface removed
|
||||
- Ran: `rg "fallbackRevertStrategy" src/hooks/session-recovery/` → only comment found, function removed
|
||||
- Ran: `rg "createSessionRecoveryHook" src/hooks/` → exports intact
|
||||
|
||||
### LEARNINGS
|
||||
- `ThinkingPart` interface vs `ThinkingPartType` type vs `prependThinkingPart` function - different entities, verify before removing
|
||||
- `fallbackRevertStrategy` was likely a planned feature that never got integrated into the recovery flow
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-08 19:04] - Task 3: Remove unused builtinMcps export from MCP module
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - `builtinMcps` export was genuinely unused (no external importers)
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Removed `export const builtinMcps = allBuiltinMcps` from line 24
|
||||
- Kept `allBuiltinMcps` const - used internally by `createBuiltinMcps` function
|
||||
- Kept `createBuiltinMcps` function - actively used in src/index.ts:89
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, bundled 200 modules
|
||||
- Ran: `rg "builtinMcps" src/mcp/index.ts` → no matches (export removed)
|
||||
- Ran: `rg "createBuiltinMcps" src/mcp/index.ts` → function still exists
|
||||
|
||||
### LEARNINGS
|
||||
- `createBuiltinMcps` function vs `builtinMcps` export - function is used, direct export is not
|
||||
- Internal const `allBuiltinMcps` should be kept since it's referenced by the function
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.30",
|
||||
"version": "0.3.2",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -44,13 +44,14 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
2
src/features/claude-code-agent-loader/index.ts
Normal file
2
src/features/claude-code-agent-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
93
src/features/claude-code-agent-loader/loader.ts
Normal file
93
src/features/claude-code-agent-loader/loader.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename } from "path"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
if (!toolsStr) return undefined
|
||||
|
||||
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
if (tools.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of tools) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
|
||||
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
|
||||
}
|
||||
|
||||
function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] {
|
||||
if (!existsSync(agentsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(agentsDir, { withFileTypes: true })
|
||||
const agents: LoadedAgent[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const agentPath = join(agentsDir, entry.name)
|
||||
const agentName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
|
||||
|
||||
const name = data.name || agentName
|
||||
const originalDescription = data.description || ""
|
||||
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const config: AgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: "subagent",
|
||||
prompt: body.trim(),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
agents.push({
|
||||
name,
|
||||
path: agentPath,
|
||||
config,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
export function loadUserAgents(): Record<string, AgentConfig> {
|
||||
const userAgentsDir = join(homedir(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(userAgentsDir, "user")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadProjectAgents(): Record<string, AgentConfig> {
|
||||
const projectAgentsDir = join(process.cwd(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(projectAgentsDir, "project")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
17
src/features/claude-code-agent-loader/types.ts
Normal file
17
src/features/claude-code-agent-loader/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentScope = "user" | "project"
|
||||
|
||||
export interface AgentFrontmatter {
|
||||
name?: string
|
||||
description?: string
|
||||
model?: string
|
||||
tools?: string
|
||||
}
|
||||
|
||||
export interface LoadedAgent {
|
||||
name: string
|
||||
path: string
|
||||
config: AgentConfig
|
||||
scope: AgentScope
|
||||
}
|
||||
2
src/features/claude-code-command-loader/index.ts
Normal file
2
src/features/claude-code-command-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
94
src/features/claude-code-command-loader/loader.ts
Normal file
94
src/features/claude-code-command-loader/loader.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
|
||||
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
|
||||
}
|
||||
|
||||
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(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 definition: CommandDefinition = {
|
||||
name: commandName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const cmd of commands) {
|
||||
result[cmd.name] = cmd.definition
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadUserCommands(): Record<string, CommandDefinition> {
|
||||
const userCommandsDir = join(homedir(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadProjectCommands(): Record<string, CommandDefinition> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
26
src/features/claude-code-command-loader/types.ts
Normal file
26
src/features/claude-code-command-loader/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
template: string
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
}
|
||||
|
||||
export interface CommandFrontmatter {
|
||||
description?: string
|
||||
"argument-hint"?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
export interface LoadedCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: CommandScope
|
||||
}
|
||||
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function expandEnvVars(value: string): string {
|
||||
return value.replace(
|
||||
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
|
||||
(_, varName: string, defaultValue?: string) => {
|
||||
const envValue = process.env[varName]
|
||||
if (envValue !== undefined) return envValue
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
return ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function expandEnvVarsInObject<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj === "string") return expandEnvVars(obj) as T
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => expandEnvVarsInObject(item)) as T
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = expandEnvVarsInObject(value)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
11
src/features/claude-code-mcp-loader/index.ts
Normal file
11
src/features/claude-code-mcp-loader/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* MCP Configuration Loader
|
||||
*
|
||||
* Loads Claude Code .mcp.json format configurations from multiple scopes
|
||||
* and transforms them to OpenCode SDK format
|
||||
*/
|
||||
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
export * from "./transformer"
|
||||
export * from "./env-expander"
|
||||
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import type {
|
||||
ClaudeCodeMcpConfig,
|
||||
LoadedMcpServer,
|
||||
McpLoadResult,
|
||||
McpScope,
|
||||
} from "./types"
|
||||
import { transformMcpServer } from "./transformer"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface McpConfigPath {
|
||||
path: string
|
||||
scope: McpScope
|
||||
}
|
||||
|
||||
function getMcpConfigPaths(): McpConfigPath[] {
|
||||
const home = homedir()
|
||||
const cwd = process.cwd()
|
||||
|
||||
return [
|
||||
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
|
||||
{ path: join(cwd, ".mcp.json"), scope: "project" },
|
||||
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
|
||||
]
|
||||
}
|
||||
|
||||
async function loadMcpConfigFile(
|
||||
filePath: string
|
||||
): Promise<ClaudeCodeMcpConfig | null> {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(filePath).text()
|
||||
return JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
} catch (error) {
|
||||
log(`Failed to load MCP config from ${filePath}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMcpConfigs(): Promise<McpLoadResult> {
|
||||
const servers: McpLoadResult["servers"] = {}
|
||||
const loadedServers: LoadedMcpServer[] = []
|
||||
const paths = getMcpConfigPaths()
|
||||
|
||||
for (const { path, scope } of paths) {
|
||||
const config = await loadMcpConfigFile(path)
|
||||
if (!config?.mcpServers) continue
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
if (serverConfig.disabled) {
|
||||
log(`Skipping disabled MCP server "${name}"`, { path })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const transformed = transformMcpServer(name, serverConfig)
|
||||
servers[name] = transformed
|
||||
|
||||
const existingIndex = loadedServers.findIndex((s) => s.name === name)
|
||||
if (existingIndex !== -1) {
|
||||
loadedServers.splice(existingIndex, 1)
|
||||
}
|
||||
|
||||
loadedServers.push({ name, scope, config: transformed })
|
||||
|
||||
log(`Loaded MCP server "${name}" from ${scope}`, { path })
|
||||
} catch (error) {
|
||||
log(`Failed to transform MCP server "${name}"`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { servers, loadedServers }
|
||||
}
|
||||
|
||||
export function formatLoadedServersForToast(
|
||||
loadedServers: LoadedMcpServer[]
|
||||
): string {
|
||||
if (loadedServers.length === 0) return ""
|
||||
|
||||
return loadedServers
|
||||
.map((server) => `${server.name} (${server.scope})`)
|
||||
.join(", ")
|
||||
}
|
||||
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
ClaudeCodeMcpServer,
|
||||
McpLocalConfig,
|
||||
McpRemoteConfig,
|
||||
McpServerConfig,
|
||||
} from "./types"
|
||||
import { expandEnvVarsInObject } from "./env-expander"
|
||||
|
||||
export function transformMcpServer(
|
||||
name: string,
|
||||
server: ClaudeCodeMcpServer
|
||||
): McpServerConfig {
|
||||
const expanded = expandEnvVarsInObject(server)
|
||||
const serverType = expanded.type ?? "stdio"
|
||||
|
||||
if (serverType === "http" || serverType === "sse") {
|
||||
if (!expanded.url) {
|
||||
throw new Error(
|
||||
`MCP server "${name}" requires url for type "${serverType}"`
|
||||
)
|
||||
}
|
||||
|
||||
const config: McpRemoteConfig = {
|
||||
type: "remote",
|
||||
url: expanded.url,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if (expanded.headers && Object.keys(expanded.headers).length > 0) {
|
||||
config.headers = expanded.headers
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
if (!expanded.command) {
|
||||
throw new Error(`MCP server "${name}" requires command for stdio type`)
|
||||
}
|
||||
|
||||
const commandArray = [expanded.command, ...(expanded.args ?? [])]
|
||||
|
||||
const config: McpLocalConfig = {
|
||||
type: "local",
|
||||
command: commandArray,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if (expanded.env && Object.keys(expanded.env).length > 0) {
|
||||
config.environment = expanded.env
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
42
src/features/claude-code-mcp-loader/types.ts
Normal file
42
src/features/claude-code-mcp-loader/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type McpScope = "user" | "project" | "local"
|
||||
|
||||
export interface ClaudeCodeMcpServer {
|
||||
type?: "http" | "sse" | "stdio"
|
||||
url?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ClaudeCodeMcpConfig {
|
||||
mcpServers?: Record<string, ClaudeCodeMcpServer>
|
||||
}
|
||||
|
||||
export interface McpLocalConfig {
|
||||
type: "local"
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface McpRemoteConfig {
|
||||
type: "remote"
|
||||
url: string
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type McpServerConfig = McpLocalConfig | McpRemoteConfig
|
||||
|
||||
export interface LoadedMcpServer {
|
||||
name: string
|
||||
scope: McpScope
|
||||
config: McpServerConfig
|
||||
}
|
||||
|
||||
export interface McpLoadResult {
|
||||
servers: Record<string, McpServerConfig>
|
||||
loadedServers: LoadedMcpServer[]
|
||||
}
|
||||
21
src/features/claude-code-session-state/detector.ts
Normal file
21
src/features/claude-code-session-state/detector.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = errObj.message as string | undefined
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message?.includes("abort")) return true
|
||||
const msgLower = message?.toLowerCase()
|
||||
if (msgLower?.includes("aborted") || msgLower?.includes("cancelled") || msgLower?.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
3
src/features/claude-code-session-state/index.ts
Normal file
3
src/features/claude-code-session-state/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./state"
|
||||
export * from "./detector"
|
||||
31
src/features/claude-code-session-state/state.ts
Normal file
31
src/features/claude-code-session-state/state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SessionErrorState, SessionInterruptState } from "./types"
|
||||
|
||||
export const sessionErrorState = new Map<string, SessionErrorState>()
|
||||
export const sessionInterruptState = new Map<string, SessionInterruptState>()
|
||||
export const subagentSessions = new Set<string>()
|
||||
export const sessionFirstMessageProcessed = new Set<string>()
|
||||
|
||||
export let currentSessionID: string | undefined
|
||||
export let currentSessionTitle: string | undefined
|
||||
export let mainSessionID: string | undefined
|
||||
|
||||
export function setCurrentSession(id: string | undefined, title: string | undefined) {
|
||||
currentSessionID = id
|
||||
currentSessionTitle = title
|
||||
}
|
||||
|
||||
export function setMainSession(id: string | undefined) {
|
||||
mainSessionID = id
|
||||
}
|
||||
|
||||
export function getCurrentSessionID(): string | undefined {
|
||||
return currentSessionID
|
||||
}
|
||||
|
||||
export function getCurrentSessionTitle(): string | undefined {
|
||||
return currentSessionTitle
|
||||
}
|
||||
|
||||
export function getMainSessionID(): string | undefined {
|
||||
return mainSessionID
|
||||
}
|
||||
8
src/features/claude-code-session-state/types.ts
Normal file
8
src/features/claude-code-session-state/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SessionErrorState {
|
||||
hasError: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface SessionInterruptState {
|
||||
interrupted: boolean
|
||||
}
|
||||
2
src/features/claude-code-skill-loader/index.ts
Normal file
2
src/features/claude-code-skill-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
85
src/features/claude-code-skill-loader/loader.ts
Normal file
85
src/features/claude-code-skill-loader/loader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync, readlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, resolve } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
|
||||
|
||||
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: LoadedSkillAsCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(skillsDir, entry.name)
|
||||
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||
|
||||
let resolvedPath = skillPath
|
||||
if (statSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
||||
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
|
||||
}
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
if (!existsSync(skillMdPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
|
||||
const skillName = data.name || entry.name
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model),
|
||||
}
|
||||
|
||||
skills.push({
|
||||
name: skillName,
|
||||
path: resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const userSkillsDir = join(homedir(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
|
||||
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(projectSkillsDir, "project")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
16
src/features/claude-code-skill-loader/types.ts
Normal file
16
src/features/claude-code-skill-loader/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
|
||||
export type SkillScope = "user" | "project"
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string
|
||||
description: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LoadedSkillAsCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: SkillScope
|
||||
}
|
||||
8
src/features/hook-message-injector/constants.ts
Normal file
8
src/features/hook-message-injector/constants.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { join } from "node:path"
|
||||
import { homedir } from "node:os"
|
||||
|
||||
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
2
src/features/hook-message-injector/index.ts
Normal file
2
src/features/hook-message-injector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { injectHookMessage } from "./injector"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
141
src/features/hook-message-injector/injector.ts
Normal file
141
src/features/hook-message-injector/injector.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
|
||||
import type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
|
||||
interface StoredMessage {
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
|
||||
try {
|
||||
const files = readdirSync(messageDir)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.sort()
|
||||
.reverse()
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
const msg = JSON.parse(content) as StoredMessage
|
||||
if (msg.agent && msg.model?.providerID && msg.model?.modelID) {
|
||||
return msg
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function generateMessageId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 14)
|
||||
return `msg_${timestamp}${random}`
|
||||
}
|
||||
|
||||
function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
|
||||
function getOrCreateMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) {
|
||||
mkdirSync(MESSAGE_STORAGE, { recursive: true })
|
||||
}
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
mkdirSync(directPath, { recursive: true })
|
||||
return directPath
|
||||
}
|
||||
|
||||
export function injectHookMessage(
|
||||
sessionID: string,
|
||||
hookContent: string,
|
||||
originalMessage: OriginalMessageContext
|
||||
): boolean {
|
||||
const messageDir = getOrCreateMessageDir(sessionID)
|
||||
|
||||
const needsFallback =
|
||||
!originalMessage.agent ||
|
||||
!originalMessage.model?.providerID ||
|
||||
!originalMessage.model?.modelID
|
||||
|
||||
const fallback = needsFallback ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const now = Date.now()
|
||||
const messageID = generateMessageId()
|
||||
const partID = generatePartId()
|
||||
|
||||
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
|
||||
const resolvedModel =
|
||||
originalMessage.model?.providerID && originalMessage.model?.modelID
|
||||
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
|
||||
: fallback?.model?.providerID && fallback?.model?.modelID
|
||||
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
|
||||
: undefined
|
||||
const resolvedTools = originalMessage.tools ?? fallback?.tools
|
||||
|
||||
const messageMeta: MessageMeta = {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: {
|
||||
created: now,
|
||||
},
|
||||
agent: resolvedAgent,
|
||||
model: resolvedModel,
|
||||
path:
|
||||
originalMessage.path?.cwd
|
||||
? {
|
||||
cwd: originalMessage.path.cwd,
|
||||
root: originalMessage.path.root ?? "/",
|
||||
}
|
||||
: undefined,
|
||||
tools: resolvedTools,
|
||||
}
|
||||
|
||||
const textPart: TextPart = {
|
||||
id: partID,
|
||||
type: "text",
|
||||
text: hookContent,
|
||||
synthetic: true,
|
||||
time: {
|
||||
start: now,
|
||||
end: now,
|
||||
},
|
||||
messageID,
|
||||
sessionID,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2))
|
||||
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2))
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
45
src/features/hook-message-injector/types.ts
Normal file
45
src/features/hook-message-injector/types.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface MessageMeta {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
time: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
path?: {
|
||||
cwd: string
|
||||
root: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface OriginalMessageContext {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
root?: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface TextPart {
|
||||
id: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic: boolean
|
||||
time: {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
messageID: string
|
||||
sessionID: string
|
||||
}
|
||||
105
src/hooks/claude-code-hooks/config-loader.ts
Normal file
105
src/hooks/claude-code-hooks/config-loader.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import type { ClaudeHookEvent } from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export interface DisabledHooksConfig {
|
||||
Stop?: string[]
|
||||
PreToolUse?: string[]
|
||||
PostToolUse?: string[]
|
||||
UserPromptSubmit?: string[]
|
||||
}
|
||||
|
||||
export interface PluginExtendedConfig {
|
||||
disabledHooks?: DisabledHooksConfig
|
||||
}
|
||||
|
||||
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
|
||||
|
||||
function getProjectConfigPath(): string {
|
||||
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
|
||||
}
|
||||
|
||||
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
|
||||
if (!existsSync(path)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(path).text()
|
||||
return JSON.parse(content) as PluginExtendedConfig
|
||||
} catch (error) {
|
||||
log("Failed to load config", { path, error })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mergeDisabledHooks(
|
||||
base: DisabledHooksConfig | undefined,
|
||||
override: DisabledHooksConfig | undefined
|
||||
): DisabledHooksConfig {
|
||||
if (!override) return base ?? {}
|
||||
if (!base) return override
|
||||
|
||||
return {
|
||||
Stop: override.Stop ?? base.Stop,
|
||||
PreToolUse: override.PreToolUse ?? base.PreToolUse,
|
||||
PostToolUse: override.PostToolUse ?? base.PostToolUse,
|
||||
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
|
||||
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
|
||||
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
|
||||
|
||||
const merged: PluginExtendedConfig = {
|
||||
disabledHooks: mergeDisabledHooks(
|
||||
userConfig?.disabledHooks,
|
||||
projectConfig?.disabledHooks
|
||||
),
|
||||
}
|
||||
|
||||
if (userConfig || projectConfig) {
|
||||
log("Plugin extended config loaded", {
|
||||
userConfigExists: userConfig !== null,
|
||||
projectConfigExists: projectConfig !== null,
|
||||
mergedDisabledHooks: merged.disabledHooks,
|
||||
})
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const regexCache = new Map<string, RegExp>()
|
||||
|
||||
function getRegex(pattern: string): RegExp {
|
||||
let regex = regexCache.get(pattern)
|
||||
if (!regex) {
|
||||
try {
|
||||
regex = new RegExp(pattern)
|
||||
regexCache.set(pattern, regex)
|
||||
} catch {
|
||||
regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
regexCache.set(pattern, regex)
|
||||
}
|
||||
}
|
||||
return regex
|
||||
}
|
||||
|
||||
export function isHookCommandDisabled(
|
||||
eventType: ClaudeHookEvent,
|
||||
command: string,
|
||||
config: PluginExtendedConfig | null
|
||||
): boolean {
|
||||
if (!config?.disabledHooks) return false
|
||||
|
||||
const patterns = config.disabledHooks[eventType]
|
||||
if (!patterns || patterns.length === 0) return false
|
||||
|
||||
return patterns.some((pattern) => {
|
||||
const regex = getRegex(pattern)
|
||||
return regex.test(command)
|
||||
})
|
||||
}
|
||||
100
src/hooks/claude-code-hooks/config.ts
Normal file
100
src/hooks/claude-code-hooks/config.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
|
||||
|
||||
interface RawHookMatcher {
|
||||
matcher?: string
|
||||
pattern?: string
|
||||
hooks: HookCommand[]
|
||||
}
|
||||
|
||||
interface RawClaudeHooksConfig {
|
||||
PreToolUse?: RawHookMatcher[]
|
||||
PostToolUse?: RawHookMatcher[]
|
||||
UserPromptSubmit?: RawHookMatcher[]
|
||||
Stop?: RawHookMatcher[]
|
||||
}
|
||||
|
||||
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
|
||||
return {
|
||||
matcher: raw.matcher ?? raw.pattern ?? "*",
|
||||
hooks: raw.hooks,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
|
||||
const result: ClaudeHooksConfig = {}
|
||||
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
]
|
||||
|
||||
for (const eventType of eventTypes) {
|
||||
if (raw[eventType]) {
|
||||
result[eventType] = raw[eventType].map(normalizeHookMatcher)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function getClaudeSettingsPaths(customPath?: string): string[] {
|
||||
const home = homedir()
|
||||
const paths = [
|
||||
join(home, ".claude", "settings.json"),
|
||||
join(process.cwd(), ".claude", "settings.json"),
|
||||
join(process.cwd(), ".claude", "settings.local.json"),
|
||||
]
|
||||
|
||||
if (customPath && existsSync(customPath)) {
|
||||
paths.unshift(customPath)
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
function mergeHooksConfig(
|
||||
base: ClaudeHooksConfig,
|
||||
override: ClaudeHooksConfig
|
||||
): ClaudeHooksConfig {
|
||||
const result: ClaudeHooksConfig = { ...base }
|
||||
const eventTypes: (keyof ClaudeHooksConfig)[] = [
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
]
|
||||
for (const eventType of eventTypes) {
|
||||
if (override[eventType]) {
|
||||
result[eventType] = [...(base[eventType] || []), ...override[eventType]]
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export async function loadClaudeHooksConfig(
|
||||
customSettingsPath?: string
|
||||
): Promise<ClaudeHooksConfig | null> {
|
||||
const paths = getClaudeSettingsPaths(customSettingsPath)
|
||||
let mergedConfig: ClaudeHooksConfig = {}
|
||||
|
||||
for (const settingsPath of paths) {
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const content = await Bun.file(settingsPath).text()
|
||||
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
|
||||
if (settings.hooks) {
|
||||
const normalizedHooks = normalizeHooksConfig(settings.hooks)
|
||||
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
|
||||
}
|
||||
336
src/hooks/claude-code-hooks/index.ts
Normal file
336
src/hooks/claude-code-hooks/index.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { loadClaudeHooksConfig } from "./config"
|
||||
import { loadPluginExtendedConfig } from "./config-loader"
|
||||
import {
|
||||
executePreToolUseHooks,
|
||||
type PreToolUseContext,
|
||||
} from "./pre-tool-use"
|
||||
import {
|
||||
executePostToolUseHooks,
|
||||
type PostToolUseContext,
|
||||
type PostToolUseClient,
|
||||
} from "./post-tool-use"
|
||||
import {
|
||||
executeUserPromptSubmitHooks,
|
||||
type UserPromptSubmitContext,
|
||||
type MessagePart,
|
||||
} from "./user-prompt-submit"
|
||||
import {
|
||||
executeStopHooks,
|
||||
type StopContext,
|
||||
} from "./stop"
|
||||
import { cacheToolInput, getToolInput } from "./tool-input-cache"
|
||||
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
|
||||
import type { PluginConfig } from "./types"
|
||||
import { log, isHookDisabled } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
|
||||
|
||||
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
},
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const interruptState = sessionInterruptState.get(input.sessionID)
|
||||
if (interruptState?.interrupted) {
|
||||
log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
|
||||
const prompt = textParts.map((p) => p.text ?? "").join("\n")
|
||||
|
||||
recordUserMessage(input.sessionID, prompt)
|
||||
|
||||
const messageParts: MessagePart[] = textParts.map((p) => ({
|
||||
type: p.type as "text",
|
||||
text: p.text,
|
||||
}))
|
||||
|
||||
const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID)
|
||||
if (interruptStateBeforeHooks?.interrupted) {
|
||||
log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
let parentSessionId: string | undefined
|
||||
try {
|
||||
const sessionInfo = await ctx.client.session.get({
|
||||
path: { id: input.sessionID },
|
||||
})
|
||||
parentSessionId = sessionInfo.data?.parentID
|
||||
} catch {}
|
||||
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
if (isFirstMessage) {
|
||||
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "UserPromptSubmit")) {
|
||||
const userPromptCtx: UserPromptSubmitContext = {
|
||||
sessionId: input.sessionID,
|
||||
parentSessionId,
|
||||
prompt,
|
||||
parts: messageParts,
|
||||
cwd: ctx.directory,
|
||||
}
|
||||
|
||||
const result = await executeUserPromptSubmitHooks(
|
||||
userPromptCtx,
|
||||
claudeConfig,
|
||||
extendedConfig
|
||||
)
|
||||
|
||||
if (result.block) {
|
||||
throw new Error(result.reason ?? "Hook blocked the prompt")
|
||||
}
|
||||
|
||||
const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID)
|
||||
if (interruptStateAfterHooks?.interrupted) {
|
||||
log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
|
||||
|
||||
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
|
||||
|
||||
if (!isHookDisabled(config, "PreToolUse")) {
|
||||
const preCtx: PreToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: output.args as Record<string, unknown>,
|
||||
cwd: ctx.directory,
|
||||
toolUseId: input.callID,
|
||||
}
|
||||
|
||||
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
|
||||
|
||||
if (result.decision === "deny") {
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "PreToolUse Hook Executed",
|
||||
message: `✗ ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
|
||||
variant: "error",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
throw new Error(result.reason ?? "Hook blocked the operation")
|
||||
}
|
||||
|
||||
if (result.modifiedInput) {
|
||||
Object.assign(output.args as Record<string, unknown>, result.modifiedInput)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
|
||||
|
||||
recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record<string, unknown>) || {})
|
||||
|
||||
if (!isHookDisabled(config, "PostToolUse")) {
|
||||
const postClient: PostToolUseClient = {
|
||||
session: {
|
||||
messages: (opts) => ctx.client.session.messages(opts),
|
||||
},
|
||||
}
|
||||
|
||||
const postCtx: PostToolUseContext = {
|
||||
sessionId: input.sessionID,
|
||||
toolName: input.tool,
|
||||
toolInput: cachedInput,
|
||||
toolOutput: {
|
||||
title: input.tool,
|
||||
output: output.output,
|
||||
metadata: output.metadata as Record<string, unknown>,
|
||||
},
|
||||
cwd: ctx.directory,
|
||||
transcriptPath: getTranscriptPath(input.sessionID),
|
||||
toolUseId: input.callID,
|
||||
client: postClient,
|
||||
permissionMode: "bypassPermissions",
|
||||
}
|
||||
|
||||
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
|
||||
|
||||
if (result.block) {
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "PostToolUse Hook Warning",
|
||||
message: result.reason ?? "Hook returned warning",
|
||||
variant: "warning",
|
||||
duration: 4000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
if (result.warnings && result.warnings.length > 0) {
|
||||
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
|
||||
}
|
||||
|
||||
if (result.message) {
|
||||
output.output = `${output.output}\n\n${result.message}`
|
||||
}
|
||||
|
||||
if (result.hookName) {
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "PostToolUse Hook Executed",
|
||||
message: `▶ ${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`,
|
||||
variant: "success",
|
||||
duration: 2000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
event: async (input: { event: { type: string; properties?: unknown } }) => {
|
||||
const { event } = input
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
sessionErrorState.set(sessionID, {
|
||||
hasError: true,
|
||||
errorMessage: String(props?.error ?? "Unknown error"),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionErrorState.delete(sessionInfo.id)
|
||||
sessionInterruptState.delete(sessionInfo.id)
|
||||
sessionFirstMessageProcessed.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
const claudeConfig = await loadClaudeHooksConfig()
|
||||
const extendedConfig = await loadPluginExtendedConfig()
|
||||
|
||||
const errorStateBefore = sessionErrorState.get(sessionID)
|
||||
const endedWithErrorBefore = errorStateBefore?.hasError === true
|
||||
const interruptStateBefore = sessionInterruptState.get(sessionID)
|
||||
const interruptedBefore = interruptStateBefore?.interrupted === true
|
||||
|
||||
let parentSessionId: string | undefined
|
||||
try {
|
||||
const sessionInfo = await ctx.client.session.get({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
parentSessionId = sessionInfo.data?.parentID
|
||||
} catch {}
|
||||
|
||||
if (!isHookDisabled(config, "Stop")) {
|
||||
const stopCtx: StopContext = {
|
||||
sessionId: sessionID,
|
||||
parentSessionId,
|
||||
cwd: ctx.directory,
|
||||
}
|
||||
|
||||
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
|
||||
|
||||
const errorStateAfter = sessionErrorState.get(sessionID)
|
||||
const endedWithErrorAfter = errorStateAfter?.hasError === true
|
||||
const interruptStateAfter = sessionInterruptState.get(sessionID)
|
||||
const interruptedAfter = interruptStateAfter?.interrupted === true
|
||||
|
||||
const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter
|
||||
|
||||
if (shouldBypass && stopResult.block) {
|
||||
const interrupted = interruptedBefore || interruptedAfter
|
||||
const endedWithError = endedWithErrorBefore || endedWithErrorAfter
|
||||
log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError })
|
||||
} else if (stopResult.block && stopResult.injectPrompt) {
|
||||
log("Stop hook returned block with inject_prompt", { sessionID })
|
||||
ctx.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch((err: unknown) => log("Failed to inject prompt from Stop hook", err))
|
||||
} else if (stopResult.block) {
|
||||
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
|
||||
}
|
||||
}
|
||||
|
||||
sessionErrorState.delete(sessionID)
|
||||
sessionInterruptState.delete(sessionID)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal file
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Plugin configuration for Claude Code hooks execution
|
||||
* Contains settings for hook command execution (zsh, etc.)
|
||||
*/
|
||||
|
||||
export const DEFAULT_CONFIG = {
|
||||
forceZsh: true,
|
||||
zshPath: "/bin/zsh",
|
||||
}
|
||||
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type {
|
||||
PostToolUseInput,
|
||||
PostToolUseOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
export interface PostToolUseClient {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostToolUseContext {
|
||||
sessionId: string
|
||||
toolName: string
|
||||
toolInput: Record<string, unknown>
|
||||
toolOutput: Record<string, unknown>
|
||||
cwd: string
|
||||
transcriptPath?: string // Fallback for append-based transcript
|
||||
toolUseId?: string
|
||||
client?: PostToolUseClient
|
||||
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface PostToolUseResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
message?: string
|
||||
warnings?: string[]
|
||||
elapsedMs?: number
|
||||
hookName?: string
|
||||
toolName?: string
|
||||
additionalContext?: string
|
||||
continue?: boolean
|
||||
stopReason?: string
|
||||
suppressOutput?: boolean
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
export async function executePostToolUseHooks(
|
||||
ctx: PostToolUseContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<PostToolUseResult> {
|
||||
if (!config) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
const transformedToolName = transformToolName(ctx.toolName)
|
||||
const matchers = findMatchingHooks(config, "PostToolUse", transformedToolName)
|
||||
if (matchers.length === 0) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
// PORT FROM DISABLED: Build Claude Code compatible transcript (temp file)
|
||||
let tempTranscriptPath: string | null = null
|
||||
|
||||
try {
|
||||
// Try to build full transcript from API if client available
|
||||
if (ctx.client) {
|
||||
tempTranscriptPath = await buildTranscriptFromSession(
|
||||
ctx.client,
|
||||
ctx.sessionId,
|
||||
ctx.cwd,
|
||||
ctx.toolName,
|
||||
ctx.toolInput
|
||||
)
|
||||
}
|
||||
|
||||
const stdinData: PostToolUseInput = {
|
||||
session_id: ctx.sessionId,
|
||||
// Use temp transcript if available, otherwise fallback to append-based
|
||||
transcript_path: tempTranscriptPath ?? ctx.transcriptPath,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_name: transformedToolName,
|
||||
tool_input: objectToSnakeCase(ctx.toolInput),
|
||||
tool_response: objectToSnakeCase(ctx.toolOutput),
|
||||
tool_use_id: ctx.toolUseId,
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
const messages: string[] = []
|
||||
const warnings: string[] = []
|
||||
let firstHookName: string | undefined
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
|
||||
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
|
||||
continue
|
||||
}
|
||||
|
||||
const hookName = hook.command.split("/").pop() || hook.command
|
||||
if (!firstHookName) firstHookName = hookName
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.stdout) {
|
||||
messages.push(result.stdout)
|
||||
}
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
if (result.stderr) {
|
||||
warnings.push(`[${hookName}]\n${result.stderr.trim()}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.exitCode === 0 && result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
message: messages.join("\n"),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
|
||||
return {
|
||||
block: false,
|
||||
message: messages.join("\n"),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
message: messages.join("\n"),
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
additionalContext: output.hookSpecificOutput?.additionalContext,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const elapsedMs = Date.now() - startTime
|
||||
|
||||
return {
|
||||
block: false,
|
||||
message: messages.length > 0 ? messages.join("\n") : undefined,
|
||||
warnings: warnings.length > 0 ? warnings : undefined,
|
||||
elapsedMs,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
}
|
||||
} finally {
|
||||
// PORT FROM DISABLED: Cleanup temp file to avoid disk accumulation
|
||||
deleteTempTranscript(tempTranscriptPath)
|
||||
}
|
||||
}
|
||||
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal file
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type {
|
||||
PreToolUseInput,
|
||||
PreToolUseOutput,
|
||||
PermissionDecision,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
export interface PreToolUseContext {
|
||||
sessionId: string
|
||||
toolName: string
|
||||
toolInput: Record<string, unknown>
|
||||
cwd: string
|
||||
transcriptPath?: string
|
||||
toolUseId?: string
|
||||
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface PreToolUseResult {
|
||||
decision: PermissionDecision
|
||||
reason?: string
|
||||
modifiedInput?: Record<string, unknown>
|
||||
elapsedMs?: number
|
||||
hookName?: string
|
||||
toolName?: string
|
||||
inputLines?: string
|
||||
// Common output fields (Claude Code spec)
|
||||
continue?: boolean
|
||||
stopReason?: string
|
||||
suppressOutput?: boolean
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
function buildInputLines(toolInput: Record<string, unknown>): string {
|
||||
return Object.entries(toolInput)
|
||||
.slice(0, 3)
|
||||
.map(([key, val]) => {
|
||||
const valStr = String(val).slice(0, 40)
|
||||
return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}`
|
||||
})
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
export async function executePreToolUseHooks(
|
||||
ctx: PreToolUseContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<PreToolUseResult> {
|
||||
if (!config) {
|
||||
return { decision: "allow" }
|
||||
}
|
||||
|
||||
const transformedToolName = transformToolName(ctx.toolName)
|
||||
const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName)
|
||||
if (matchers.length === 0) {
|
||||
return { decision: "allow" }
|
||||
}
|
||||
|
||||
const stdinData: PreToolUseInput = {
|
||||
session_id: ctx.sessionId,
|
||||
transcript_path: ctx.transcriptPath,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: transformedToolName,
|
||||
tool_input: objectToSnakeCase(ctx.toolInput),
|
||||
tool_use_id: ctx.toolUseId,
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
let firstHookName: string | undefined
|
||||
const inputLines = buildInputLines(ctx.toolInput)
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
|
||||
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
|
||||
continue
|
||||
}
|
||||
|
||||
const hookName = hook.command.split("/").pop() || hook.command
|
||||
if (!firstHookName) firstHookName = hookName
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.exitCode === 2) {
|
||||
return {
|
||||
decision: "deny",
|
||||
reason: result.stderr || result.stdout || "Hook blocked the operation",
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode === 1) {
|
||||
return {
|
||||
decision: "ask",
|
||||
reason: result.stderr || result.stdout,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as PreToolUseOutput
|
||||
|
||||
// Handle deprecated decision/reason fields (Claude Code backward compat)
|
||||
let decision: PermissionDecision | undefined
|
||||
let reason: string | undefined
|
||||
let modifiedInput: Record<string, unknown> | undefined
|
||||
|
||||
if (output.hookSpecificOutput?.permissionDecision) {
|
||||
decision = output.hookSpecificOutput.permissionDecision
|
||||
reason = output.hookSpecificOutput.permissionDecisionReason
|
||||
modifiedInput = output.hookSpecificOutput.updatedInput
|
||||
} else if (output.decision) {
|
||||
// Map deprecated values: approve->allow, block->deny, ask->ask
|
||||
const legacyDecision = output.decision
|
||||
if (legacyDecision === "approve" || legacyDecision === "allow") {
|
||||
decision = "allow"
|
||||
} else if (legacyDecision === "block" || legacyDecision === "deny") {
|
||||
decision = "deny"
|
||||
} else if (legacyDecision === "ask") {
|
||||
decision = "ask"
|
||||
}
|
||||
reason = output.reason
|
||||
}
|
||||
|
||||
// Return if decision is set OR if any common fields are set (fallback to allow)
|
||||
const hasCommonFields = output.continue !== undefined ||
|
||||
output.stopReason !== undefined ||
|
||||
output.suppressOutput !== undefined ||
|
||||
output.systemMessage !== undefined
|
||||
|
||||
if (decision || hasCommonFields) {
|
||||
return {
|
||||
decision: decision ?? "allow",
|
||||
reason,
|
||||
modifiedInput,
|
||||
elapsedMs: Date.now() - startTime,
|
||||
hookName: firstHookName,
|
||||
toolName: transformedToolName,
|
||||
inputLines,
|
||||
continue: output.continue,
|
||||
stopReason: output.stopReason,
|
||||
suppressOutput: output.suppressOutput,
|
||||
systemMessage: output.systemMessage,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { decision: "allow" }
|
||||
}
|
||||
118
src/hooks/claude-code-hooks/stop.ts
Normal file
118
src/hooks/claude-code-hooks/stop.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
StopInput,
|
||||
StopOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { getTodoPath } from "./todo"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
// Module-level state to track stop_hook_active per session
|
||||
const stopHookActiveState = new Map<string, boolean>()
|
||||
|
||||
export function setStopHookActive(sessionId: string, active: boolean): void {
|
||||
stopHookActiveState.set(sessionId, active)
|
||||
}
|
||||
|
||||
export function getStopHookActive(sessionId: string): boolean {
|
||||
return stopHookActiveState.get(sessionId) ?? false
|
||||
}
|
||||
|
||||
export interface StopContext {
|
||||
sessionId: string
|
||||
parentSessionId?: string
|
||||
cwd: string
|
||||
transcriptPath?: string
|
||||
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
||||
stopHookActive?: boolean
|
||||
}
|
||||
|
||||
export interface StopResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
stopHookActive?: boolean
|
||||
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
injectPrompt?: string
|
||||
}
|
||||
|
||||
export async function executeStopHooks(
|
||||
ctx: StopContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<StopResult> {
|
||||
if (ctx.parentSessionId) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
const matchers = findMatchingHooks(config, "Stop")
|
||||
if (matchers.length === 0) {
|
||||
return { block: false }
|
||||
}
|
||||
|
||||
const stdinData: StopInput = {
|
||||
session_id: ctx.sessionId,
|
||||
transcript_path: ctx.transcriptPath,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "Stop",
|
||||
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
|
||||
todo_path: getTodoPath(ctx.sessionId),
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
|
||||
log("Stop hook command skipped (disabled by config)", { command: hook.command })
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
// Check exit code first - exit code 2 means block
|
||||
if (result.exitCode === 2) {
|
||||
const reason = result.stderr || result.stdout || "Blocked by stop hook"
|
||||
return {
|
||||
block: true,
|
||||
reason,
|
||||
injectPrompt: reason,
|
||||
}
|
||||
}
|
||||
|
||||
if (result.stdout) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout) as StopOutput
|
||||
if (output.stop_hook_active !== undefined) {
|
||||
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
|
||||
}
|
||||
const isBlock = output.decision === "block"
|
||||
// Determine inject_prompt: prefer explicit value, fallback to reason if blocking
|
||||
const injectPrompt = output.inject_prompt ?? (isBlock && output.reason ? output.reason : undefined)
|
||||
return {
|
||||
block: isBlock,
|
||||
reason: output.reason,
|
||||
stopHookActive: output.stop_hook_active,
|
||||
permissionMode: output.permission_mode,
|
||||
injectPrompt,
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors - hook may return non-JSON output
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { block: false }
|
||||
}
|
||||
76
src/hooks/claude-code-hooks/todo.ts
Normal file
76
src/hooks/claude-code-hooks/todo.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { join } from "path"
|
||||
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
|
||||
|
||||
const TODO_DIR = join(homedir(), ".claude", "todos")
|
||||
|
||||
export function getTodoPath(sessionId: string): string {
|
||||
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)
|
||||
}
|
||||
|
||||
function ensureTodoDir(): void {
|
||||
if (!existsSync(TODO_DIR)) {
|
||||
mkdirSync(TODO_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpenCodeTodo {
|
||||
content: string
|
||||
status: string
|
||||
priority: string
|
||||
id: string
|
||||
}
|
||||
|
||||
function toClaudeCodeFormat(item: OpenCodeTodo | TodoItem): ClaudeCodeTodoItem {
|
||||
return {
|
||||
content: item.content,
|
||||
status: item.status === "cancelled" ? "completed" : item.status,
|
||||
activeForm: item.content,
|
||||
}
|
||||
}
|
||||
|
||||
export function loadTodoFile(sessionId: string): TodoFile | null {
|
||||
const path = getTodoPath(sessionId)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
const content = JSON.parse(readFileSync(path, "utf-8"))
|
||||
if (Array.isArray(content)) {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
items: content.map((item: ClaudeCodeTodoItem, idx: number) => ({
|
||||
id: String(idx),
|
||||
content: item.content,
|
||||
status: item.status as TodoItem["status"],
|
||||
created_at: new Date().toISOString(),
|
||||
})),
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
return content
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function saveTodoFile(sessionId: string, file: TodoFile): void {
|
||||
ensureTodoDir()
|
||||
const path = getTodoPath(sessionId)
|
||||
const claudeCodeFormat: ClaudeCodeTodoItem[] = file.items.map(toClaudeCodeFormat)
|
||||
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
|
||||
}
|
||||
|
||||
export function saveOpenCodeTodos(sessionId: string, todos: OpenCodeTodo[]): void {
|
||||
ensureTodoDir()
|
||||
const path = getTodoPath(sessionId)
|
||||
const claudeCodeFormat: ClaudeCodeTodoItem[] = todos.map(toClaudeCodeFormat)
|
||||
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
|
||||
}
|
||||
|
||||
export function deleteTodoFile(sessionId: string): void {
|
||||
const path = getTodoPath(sessionId)
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path)
|
||||
}
|
||||
}
|
||||
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal file
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Caches tool_input from PreToolUse for PostToolUse
|
||||
*/
|
||||
|
||||
interface CacheEntry {
|
||||
toolInput: Record<string, unknown>
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry>()
|
||||
|
||||
const CACHE_TTL = 60000 // 1 minute
|
||||
|
||||
export function cacheToolInput(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
invocationId: string,
|
||||
toolInput: Record<string, unknown>
|
||||
): void {
|
||||
const key = `${sessionId}:${toolName}:${invocationId}`
|
||||
cache.set(key, { toolInput, timestamp: Date.now() })
|
||||
}
|
||||
|
||||
export function getToolInput(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
invocationId: string
|
||||
): Record<string, unknown> | null {
|
||||
const key = `${sessionId}:${toolName}:${invocationId}`
|
||||
const entry = cache.get(key)
|
||||
if (!entry) return null
|
||||
|
||||
cache.delete(key)
|
||||
if (Date.now() - entry.timestamp > CACHE_TTL) return null
|
||||
|
||||
return entry.toolInput
|
||||
}
|
||||
|
||||
// Periodic cleanup (every minute)
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of cache.entries()) {
|
||||
if (now - entry.timestamp > CACHE_TTL) {
|
||||
cache.delete(key)
|
||||
}
|
||||
}
|
||||
}, CACHE_TTL)
|
||||
255
src/hooks/claude-code-hooks/transcript.ts
Normal file
255
src/hooks/claude-code-hooks/transcript.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* Transcript Manager
|
||||
* Creates and manages Claude Code compatible transcript files
|
||||
*/
|
||||
import { join } from "path"
|
||||
import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs"
|
||||
import { homedir, tmpdir } from "os"
|
||||
import { randomUUID } from "crypto"
|
||||
import type { TranscriptEntry } from "./types"
|
||||
import { transformToolName } from "../../shared/tool-name"
|
||||
|
||||
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
|
||||
|
||||
export function getTranscriptPath(sessionId: string): string {
|
||||
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
|
||||
}
|
||||
|
||||
function ensureTranscriptDir(): void {
|
||||
if (!existsSync(TRANSCRIPT_DIR)) {
|
||||
mkdirSync(TRANSCRIPT_DIR, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
export function appendTranscriptEntry(
|
||||
sessionId: string,
|
||||
entry: TranscriptEntry
|
||||
): void {
|
||||
ensureTranscriptDir()
|
||||
const path = getTranscriptPath(sessionId)
|
||||
const line = JSON.stringify(entry) + "\n"
|
||||
appendFileSync(path, line)
|
||||
}
|
||||
|
||||
export function recordToolUse(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "tool_use",
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
})
|
||||
}
|
||||
|
||||
export function recordToolResult(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>,
|
||||
toolOutput: Record<string, unknown>
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "tool_result",
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: toolName,
|
||||
tool_input: toolInput,
|
||||
tool_output: toolOutput,
|
||||
})
|
||||
}
|
||||
|
||||
export function recordUserMessage(
|
||||
sessionId: string,
|
||||
content: string
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "user",
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
export function recordAssistantMessage(
|
||||
sessionId: string,
|
||||
content: string
|
||||
): void {
|
||||
appendTranscriptEntry(sessionId, {
|
||||
type: "assistant",
|
||||
timestamp: new Date().toISOString(),
|
||||
content,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claude Code Compatible Transcript Builder (PORT FROM DISABLED)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* OpenCode API response type (loosely typed)
|
||||
*/
|
||||
interface OpenCodeMessagePart {
|
||||
type: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeMessage {
|
||||
info?: {
|
||||
role?: string
|
||||
}
|
||||
parts?: OpenCodeMessagePart[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code compatible transcript entry (from disabled file)
|
||||
*/
|
||||
interface DisabledTranscriptEntry {
|
||||
type: "assistant"
|
||||
message: {
|
||||
role: "assistant"
|
||||
content: Array<{
|
||||
type: "tool_use"
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Claude Code compatible transcript from session messages
|
||||
*
|
||||
* PORT FROM DISABLED: This calls client.session.messages() API to fetch
|
||||
* the full session history and builds a JSONL file in Claude Code format.
|
||||
*
|
||||
* @param client OpenCode client instance
|
||||
* @param sessionId Session ID
|
||||
* @param directory Working directory
|
||||
* @param currentToolName Current tool being executed (added as last entry)
|
||||
* @param currentToolInput Current tool input
|
||||
* @returns Temp file path (caller must call deleteTempTranscript!)
|
||||
*/
|
||||
export async function buildTranscriptFromSession(
|
||||
client: {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
},
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
currentToolName: string,
|
||||
currentToolInput: Record<string, unknown>
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const response = await client.session.messages({
|
||||
path: { id: sessionId },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
// Handle various response formats
|
||||
const messages = (response as { "200"?: unknown[]; data?: unknown[] })["200"]
|
||||
?? (response as { data?: unknown[] }).data
|
||||
?? (Array.isArray(response) ? response : [])
|
||||
|
||||
const entries: string[] = []
|
||||
|
||||
if (Array.isArray(messages)) {
|
||||
for (const msg of messages as OpenCodeMessage[]) {
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
|
||||
for (const part of msg.parts || []) {
|
||||
if (part.type !== "tool") continue
|
||||
if (part.state?.status !== "completed") continue
|
||||
if (!part.state?.input) continue
|
||||
|
||||
const rawToolName = part.tool as string
|
||||
const toolName = transformToolName(rawToolName)
|
||||
|
||||
const entry: DisabledTranscriptEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: toolName,
|
||||
input: part.state.input,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
entries.push(JSON.stringify(entry))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always add current tool call as the last entry
|
||||
const currentEntry: DisabledTranscriptEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: transformToolName(currentToolName),
|
||||
input: currentToolInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
entries.push(JSON.stringify(currentEntry))
|
||||
|
||||
// Write to temp file
|
||||
const tempPath = join(
|
||||
tmpdir(),
|
||||
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
|
||||
)
|
||||
writeFileSync(tempPath, entries.join("\n") + "\n")
|
||||
|
||||
return tempPath
|
||||
} catch {
|
||||
// CRITICAL FIX: Even on API failure, create file with current tool entry only
|
||||
// (matching original disabled behavior - never return null with incompatible format)
|
||||
try {
|
||||
const currentEntry: DisabledTranscriptEntry = {
|
||||
type: "assistant",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
name: transformToolName(currentToolName),
|
||||
input: currentToolInput,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const tempPath = join(
|
||||
tmpdir(),
|
||||
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
|
||||
)
|
||||
writeFileSync(tempPath, JSON.stringify(currentEntry) + "\n")
|
||||
return tempPath
|
||||
} catch {
|
||||
// If even this fails, return null (truly catastrophic failure)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete temp transcript file (call in finally block)
|
||||
*
|
||||
* PORT FROM DISABLED: Cleanup mechanism to avoid disk accumulation
|
||||
*/
|
||||
export function deleteTempTranscript(path: string | null): void {
|
||||
if (!path) return
|
||||
try {
|
||||
unlinkSync(path)
|
||||
} catch {
|
||||
// Ignore deletion errors
|
||||
}
|
||||
}
|
||||
184
src/hooks/claude-code-hooks/types.ts
Normal file
184
src/hooks/claude-code-hooks/types.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Claude Code Hooks Type Definitions
|
||||
* Maps Claude Code hook concepts to OpenCode plugin events
|
||||
*/
|
||||
|
||||
export type ClaudeHookEvent =
|
||||
| "PreToolUse"
|
||||
| "PostToolUse"
|
||||
| "UserPromptSubmit"
|
||||
| "Stop"
|
||||
|
||||
export interface HookMatcher {
|
||||
matcher: string
|
||||
hooks: HookCommand[]
|
||||
}
|
||||
|
||||
export interface HookCommand {
|
||||
type: "command"
|
||||
command: string
|
||||
}
|
||||
|
||||
export interface ClaudeHooksConfig {
|
||||
PreToolUse?: HookMatcher[]
|
||||
PostToolUse?: HookMatcher[]
|
||||
UserPromptSubmit?: HookMatcher[]
|
||||
Stop?: HookMatcher[]
|
||||
}
|
||||
|
||||
export interface PreToolUseInput {
|
||||
session_id: string
|
||||
transcript_path?: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "PreToolUse"
|
||||
tool_name: string
|
||||
tool_input: Record<string, unknown>
|
||||
tool_use_id?: string
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export interface PostToolUseInput {
|
||||
session_id: string
|
||||
transcript_path?: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "PostToolUse"
|
||||
tool_name: string
|
||||
tool_input: Record<string, unknown>
|
||||
tool_response: {
|
||||
title?: string
|
||||
output?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
tool_use_id?: string
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitInput {
|
||||
session_id: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "UserPromptSubmit"
|
||||
prompt: string
|
||||
session?: {
|
||||
id: string
|
||||
}
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export type PermissionMode = "default" | "plan" | "acceptEdits" | "bypassPermissions"
|
||||
|
||||
export type HookSource = "opencode-plugin"
|
||||
|
||||
export interface StopInput {
|
||||
session_id: string
|
||||
transcript_path?: string
|
||||
cwd: string
|
||||
permission_mode?: PermissionMode
|
||||
hook_event_name: "Stop"
|
||||
stop_hook_active: boolean
|
||||
todo_path?: string
|
||||
hook_source?: HookSource
|
||||
}
|
||||
|
||||
export type PermissionDecision = "allow" | "deny" | "ask"
|
||||
|
||||
/**
|
||||
* Common JSON fields for all hook outputs (Claude Code spec)
|
||||
*/
|
||||
export interface HookCommonOutput {
|
||||
/** If false, Claude stops entirely */
|
||||
continue?: boolean
|
||||
/** Message shown to user when continue=false */
|
||||
stopReason?: string
|
||||
/** Suppress output from transcript */
|
||||
suppressOutput?: boolean
|
||||
/** Warning/message displayed to user */
|
||||
systemMessage?: string
|
||||
}
|
||||
|
||||
export interface PreToolUseOutput extends HookCommonOutput {
|
||||
/** Deprecated: use hookSpecificOutput.permissionDecision instead */
|
||||
decision?: "allow" | "deny" | "approve" | "block" | "ask"
|
||||
/** Deprecated: use hookSpecificOutput.permissionDecisionReason instead */
|
||||
reason?: string
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: "PreToolUse"
|
||||
permissionDecision: PermissionDecision
|
||||
permissionDecisionReason?: string
|
||||
updatedInput?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export interface PostToolUseOutput extends HookCommonOutput {
|
||||
decision?: "block"
|
||||
reason?: string
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: "PostToolUse"
|
||||
/** Additional context to provide to Claude */
|
||||
additionalContext?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface HookResult {
|
||||
exitCode: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
export interface TranscriptEntry {
|
||||
type: "tool_use" | "tool_result" | "user" | "assistant"
|
||||
timestamp: string
|
||||
tool_name?: string
|
||||
tool_input?: Record<string, unknown>
|
||||
tool_output?: Record<string, unknown>
|
||||
content?: string
|
||||
}
|
||||
|
||||
export interface TodoItem {
|
||||
id: string
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
priority?: "low" | "medium" | "high"
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
}
|
||||
|
||||
export interface ClaudeCodeTodoItem {
|
||||
content: string
|
||||
status: string // "pending" | "in_progress" | "completed"
|
||||
activeForm: string
|
||||
}
|
||||
|
||||
export interface TodoFile {
|
||||
session_id: string
|
||||
items: TodoItem[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface StopOutput {
|
||||
decision?: "block" | "continue"
|
||||
reason?: string
|
||||
stop_hook_active?: boolean
|
||||
permission_mode?: PermissionMode
|
||||
inject_prompt?: string
|
||||
}
|
||||
|
||||
export type ClaudeCodeContent =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
|
||||
| { type: "tool_result"; tool_use_id: string; content: string }
|
||||
|
||||
export interface ClaudeCodeMessage {
|
||||
type: "user" | "assistant"
|
||||
message: {
|
||||
role: "user" | "assistant"
|
||||
content: ClaudeCodeContent[]
|
||||
}
|
||||
}
|
||||
|
||||
export interface PluginConfig {
|
||||
disabledHooks?: boolean | ClaudeHookEvent[]
|
||||
}
|
||||
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type {
|
||||
UserPromptSubmitInput,
|
||||
PostToolUseOutput,
|
||||
ClaudeHooksConfig,
|
||||
} from "./types"
|
||||
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
|
||||
import { DEFAULT_CONFIG } from "./plugin-config"
|
||||
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
|
||||
|
||||
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
|
||||
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
|
||||
|
||||
export interface MessagePart {
|
||||
type: "text" | "tool_use" | "tool_result"
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitContext {
|
||||
sessionId: string
|
||||
parentSessionId?: string
|
||||
prompt: string
|
||||
parts: MessagePart[]
|
||||
cwd: string
|
||||
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
|
||||
}
|
||||
|
||||
export interface UserPromptSubmitResult {
|
||||
block: boolean
|
||||
reason?: string
|
||||
modifiedParts: MessagePart[]
|
||||
messages: string[]
|
||||
}
|
||||
|
||||
export async function executeUserPromptSubmitHooks(
|
||||
ctx: UserPromptSubmitContext,
|
||||
config: ClaudeHooksConfig | null,
|
||||
extendedConfig?: PluginExtendedConfig | null
|
||||
): Promise<UserPromptSubmitResult> {
|
||||
const modifiedParts = ctx.parts
|
||||
const messages: string[] = []
|
||||
|
||||
if (ctx.parentSessionId) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
if (
|
||||
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
|
||||
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
|
||||
) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
const matchers = findMatchingHooks(config, "UserPromptSubmit")
|
||||
if (matchers.length === 0) {
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
|
||||
const stdinData: UserPromptSubmitInput = {
|
||||
session_id: ctx.sessionId,
|
||||
cwd: ctx.cwd,
|
||||
permission_mode: ctx.permissionMode ?? "bypassPermissions",
|
||||
hook_event_name: "UserPromptSubmit",
|
||||
prompt: ctx.prompt,
|
||||
session: { id: ctx.sessionId },
|
||||
hook_source: "opencode-plugin",
|
||||
}
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const hook of matcher.hooks) {
|
||||
if (hook.type !== "command") continue
|
||||
|
||||
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
|
||||
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
|
||||
continue
|
||||
}
|
||||
|
||||
const result = await executeHookCommand(
|
||||
hook.command,
|
||||
JSON.stringify(stdinData),
|
||||
ctx.cwd,
|
||||
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
|
||||
)
|
||||
|
||||
if (result.stdout) {
|
||||
const output = result.stdout.trim()
|
||||
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
|
||||
messages.push(output)
|
||||
} else {
|
||||
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
try {
|
||||
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
|
||||
if (output.decision === "block") {
|
||||
return {
|
||||
block: true,
|
||||
reason: output.reason || result.stderr,
|
||||
modifiedParts,
|
||||
messages,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { block: false, modifiedParts, messages }
|
||||
}
|
||||
@@ -7,16 +7,8 @@ const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
|
||||
|
||||
You are using Anthropic Claude with 1M context window.
|
||||
Current usage has exceeded 75%.
|
||||
|
||||
RECOMMENDATIONS:
|
||||
- Consider compacting the session if available
|
||||
- Break complex tasks into smaller, focused sessions
|
||||
- Be concise in your responses
|
||||
- Avoid redundant file reads
|
||||
|
||||
You have access to 1M tokens - use them wisely. Do NOT rush or skip tasks.
|
||||
Complete your work thoroughly despite the context usage warning.`
|
||||
You have plenty of context remaining - do NOT rush or skip tasks.
|
||||
Complete your work thoroughly and methodically.`
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant"
|
||||
|
||||
9
src/hooks/directory-readme-injector/constants.ts
Normal file
9
src/hooks/directory-readme-injector/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const README_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-readme",
|
||||
);
|
||||
export const README_FILENAME = "README.md";
|
||||
126
src/hooks/directory-readme-injector/index.ts
Normal file
126
src/hooks/directory-readme-injector/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { README_FILENAME } from "./constants";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(title: string): string | null {
|
||||
if (!title) return null;
|
||||
if (title.startsWith("/")) return title;
|
||||
return resolve(ctx.directory, title);
|
||||
}
|
||||
|
||||
function findReadmeMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const readmePath = join(current, README_FILENAME);
|
||||
if (existsSync(readmePath)) {
|
||||
found.push(readmePath);
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "read") return;
|
||||
|
||||
const filePath = resolveFilePath(output.title);
|
||||
if (!filePath) return;
|
||||
|
||||
const dir = dirname(filePath);
|
||||
const cache = getSessionCache(input.sessionID);
|
||||
const readmePaths = findReadmeMdUp(dir);
|
||||
|
||||
const toInject: { path: string; content: string }[] = [];
|
||||
|
||||
for (const readmePath of readmePaths) {
|
||||
const readmeDir = dirname(readmePath);
|
||||
if (cache.has(readmeDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
toInject.push({ path: readmePath, content });
|
||||
cache.add(readmeDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
for (const { path, content } of toInject) {
|
||||
output.output += `\n\n[Project README: ${path}]\n${content}`;
|
||||
}
|
||||
|
||||
saveInjectedPaths(input.sessionID, cache);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
48
src/hooks/directory-readme-injector/storage.ts
Normal file
48
src/hooks/directory-readme-injector/storage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { README_INJECTOR_STORAGE } from "./constants";
|
||||
import type { InjectedPathsData } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(README_INJECTOR_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadInjectedPaths(sessionID: string): Set<string> {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return new Set();
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const data: InjectedPathsData = JSON.parse(content);
|
||||
return new Set(data.injectedPaths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
|
||||
if (!existsSync(README_INJECTOR_STORAGE)) {
|
||||
mkdirSync(README_INJECTOR_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const data: InjectedPathsData = {
|
||||
sessionID,
|
||||
injectedPaths: [...paths],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function clearInjectedPaths(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
5
src/hooks/directory-readme-injector/types.ts
Normal file
5
src/hooks/directory-readme-injector/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -5,5 +5,8 @@ export { createSessionRecoveryHook } from "./session-recovery";
|
||||
export { createCommentCheckerHooks } from "./comment-checker";
|
||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
|
||||
export { createDirectoryReadmeInjectorHook } from "./directory-readme-injector";
|
||||
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
|
||||
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
|
||||
export { createThinkModeHook } from "./think-mode";
|
||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findEmptyMessageByIndex,
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessagesWithThinkingBlocks,
|
||||
injectTextPart,
|
||||
@@ -54,6 +55,12 @@ function getErrorMessage(error: unknown): string {
|
||||
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
|
||||
}
|
||||
|
||||
function extractMessageIndex(error: unknown): number | null {
|
||||
const message = getErrorMessage(error)
|
||||
const match = message.match(/messages\.(\d+)/)
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
@@ -161,16 +168,26 @@ async function recoverEmptyContentMessage(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
_directory: string,
|
||||
error: unknown
|
||||
): Promise<boolean> {
|
||||
const emptyMessageIDs = findEmptyMessages(sessionID)
|
||||
const targetIndex = extractMessageIndex(error)
|
||||
const failedID = failedAssistantMsg.info?.id
|
||||
|
||||
if (emptyMessageIDs.length === 0) {
|
||||
const fallbackID = failedAssistantMsg.info?.id
|
||||
if (!fallbackID) return false
|
||||
return injectTextPart(sessionID, fallbackID, "(interrupted)")
|
||||
if (targetIndex !== null) {
|
||||
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
|
||||
if (targetMessageID) {
|
||||
return injectTextPart(sessionID, targetMessageID, "(interrupted)")
|
||||
}
|
||||
}
|
||||
|
||||
if (failedID) {
|
||||
if (injectTextPart(sessionID, failedID, "(interrupted)")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
const emptyMessageIDs = findEmptyMessages(sessionID)
|
||||
let anySuccess = false
|
||||
for (const messageID of emptyMessageIDs) {
|
||||
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
|
||||
@@ -262,15 +279,16 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
} else if (errorType === "thinking_disabled_violation") {
|
||||
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "empty_content_message") {
|
||||
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
|
||||
}
|
||||
|
||||
return success
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
}
|
||||
return success
|
||||
} catch (err) {
|
||||
console.error("[session-recovery] Recovery failed:", err)
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -42,7 +42,12 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => a.id.localeCompare(b.id))
|
||||
return messages.sort((a, b) => {
|
||||
const aTime = a.time?.created ?? 0
|
||||
const bTime = b.time?.created ?? 0
|
||||
if (aTime !== bTime) return aTime - bTime
|
||||
return a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
|
||||
export function readParts(messageID: string): StoredPart[] {
|
||||
@@ -117,13 +122,9 @@ export function findEmptyMessages(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const emptyIds: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
@@ -132,6 +133,18 @@ export function findEmptyMessages(sessionID: string): string[] {
|
||||
return emptyIds
|
||||
}
|
||||
|
||||
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
|
||||
const messages = readMessages(sessionID)
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= messages.length) return null
|
||||
|
||||
const targetMsg = messages[targetIndex]
|
||||
if (targetMsg.role !== "assistant") return null
|
||||
if (messageHasContent(targetMsg.id)) return null
|
||||
|
||||
return targetMsg.id
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
const emptyIds = findEmptyMessages(sessionID)
|
||||
return emptyIds.length > 0 ? emptyIds[0] : null
|
||||
|
||||
57
src/hooks/think-mode/detector.ts
Normal file
57
src/hooks/think-mode/detector.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const ENGLISH_PATTERNS = [/\bultrathink\b/i, /\bthink\b/i]
|
||||
|
||||
const MULTILINGUAL_KEYWORDS = [
|
||||
"생각", "고민", "검토", "제대로",
|
||||
"思考", "考虑", "考慮",
|
||||
"思考", "考え", "熟考",
|
||||
"सोच", "विचार",
|
||||
"تفكير", "تأمل",
|
||||
"চিন্তা", "ভাবনা",
|
||||
"думать", "думай", "размышлять", "размышляй",
|
||||
"pensar", "pense", "refletir", "reflita",
|
||||
"pensar", "piensa", "reflexionar", "reflexiona",
|
||||
"penser", "pense", "réfléchir", "réfléchis",
|
||||
"denken", "denk", "nachdenken",
|
||||
"suy nghĩ", "cân nhắc",
|
||||
"düşün", "düşünmek",
|
||||
"pensare", "pensa", "riflettere", "rifletti",
|
||||
"คิด", "พิจารณา",
|
||||
"myśl", "myśleć", "zastanów",
|
||||
"denken", "denk", "nadenken",
|
||||
"berpikir", "pikir", "pertimbangkan",
|
||||
"думати", "думай", "роздумувати",
|
||||
"σκέψου", "σκέφτομαι",
|
||||
"myslet", "mysli", "přemýšlet",
|
||||
"gândește", "gândi", "reflectă",
|
||||
"tänka", "tänk", "fundera",
|
||||
"gondolkodj", "gondolkodni",
|
||||
"ajattele", "ajatella", "pohdi",
|
||||
"tænk", "tænke", "overvej",
|
||||
"tenk", "tenke", "gruble",
|
||||
"חשוב", "לחשוב", "להרהר",
|
||||
"fikir", "berfikir",
|
||||
]
|
||||
|
||||
const MULTILINGUAL_PATTERNS = MULTILINGUAL_KEYWORDS.map((kw) => new RegExp(kw, "i"))
|
||||
const THINK_PATTERNS = [...ENGLISH_PATTERNS, ...MULTILINGUAL_PATTERNS]
|
||||
|
||||
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
|
||||
}
|
||||
|
||||
export function detectThinkKeyword(text: string): boolean {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
return THINK_PATTERNS.some((pattern) => pattern.test(textWithoutCode))
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("")
|
||||
}
|
||||
73
src/hooks/think-mode/index.ts
Normal file
73
src/hooks/think-mode/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||
import { getHighVariant, isAlreadyHighVariant } from "./switcher"
|
||||
import type { ThinkModeState, ThinkModeInput } from "./types"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./switcher"
|
||||
export * from "./types"
|
||||
|
||||
const thinkModeState = new Map<string, ThinkModeState>()
|
||||
|
||||
export function clearThinkModeState(sessionID: string): void {
|
||||
thinkModeState.delete(sessionID)
|
||||
}
|
||||
|
||||
export function createThinkModeHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
output: ThinkModeInput,
|
||||
sessionID: string
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
const state: ThinkModeState = {
|
||||
requested: false,
|
||||
modelSwitched: false,
|
||||
}
|
||||
|
||||
if (!detectThinkKeyword(promptText)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.requested = true
|
||||
|
||||
const currentModel = output.message.model
|
||||
if (!currentModel) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.providerID = currentModel.providerID
|
||||
state.modelID = currentModel.modelID
|
||||
|
||||
if (isAlreadyHighVariant(currentModel.modelID)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
const highVariant = getHighVariant(currentModel.modelID)
|
||||
|
||||
if (!highVariant) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
output.message.model = {
|
||||
providerID: currentModel.providerID,
|
||||
modelID: highVariant,
|
||||
}
|
||||
state.modelSwitched = true
|
||||
thinkModeState.set(sessionID, state)
|
||||
},
|
||||
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
if (props?.info?.id) {
|
||||
thinkModeState.delete(props.info.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
91
src/hooks/think-mode/switcher.ts
Normal file
91
src/hooks/think-mode/switcher.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5": "claude-opus-4-5-high",
|
||||
"gpt-5.1": "gpt-5.1-high",
|
||||
"gpt-5.1-medium": "gpt-5.1-high",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex-high",
|
||||
"gemini-3-pro": "gemini-3-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
||||
}
|
||||
|
||||
const ALREADY_HIGH: Set<string> = new Set([
|
||||
"claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5-high",
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-codex-high",
|
||||
"gemini-3-pro-high",
|
||||
])
|
||||
|
||||
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
|
||||
anthropic: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 64000,
|
||||
},
|
||||
},
|
||||
"amazon-bedrock": {
|
||||
reasoningConfig: {
|
||||
type: "enabled",
|
||||
budgetTokens: 32000,
|
||||
},
|
||||
},
|
||||
google: {
|
||||
providerOptions: {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: "HIGH",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"google-vertex": {
|
||||
providerOptions: {
|
||||
"google-vertex": {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: "HIGH",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const THINKING_CAPABLE_MODELS: Record<string, string[]> = {
|
||||
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
|
||||
"amazon-bedrock": ["claude", "anthropic"],
|
||||
google: ["gemini-2", "gemini-3"],
|
||||
"google-vertex": ["gemini-2", "gemini-3"],
|
||||
}
|
||||
|
||||
export function getHighVariant(modelID: string): string | null {
|
||||
if (ALREADY_HIGH.has(modelID)) {
|
||||
return null
|
||||
}
|
||||
return HIGH_VARIANT_MAP[modelID] ?? null
|
||||
}
|
||||
|
||||
export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
return ALREADY_HIGH.has(modelID) || modelID.endsWith("-high")
|
||||
}
|
||||
|
||||
export function getThinkingConfig(
|
||||
providerID: string,
|
||||
modelID: string
|
||||
): Record<string, unknown> | null {
|
||||
if (isAlreadyHighVariant(modelID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config = THINKING_CONFIGS[providerID]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[providerID]
|
||||
|
||||
if (!config || !capablePatterns) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelLower = modelID.toLowerCase()
|
||||
const isCapable = capablePatterns.some((pattern) =>
|
||||
modelLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
return isCapable ? config : null
|
||||
}
|
||||
20
src/hooks/think-mode/types.ts
Normal file
20
src/hooks/think-mode/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface ThinkModeState {
|
||||
requested: boolean
|
||||
modelSwitched: boolean
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface ModelRef {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
export interface MessageWithModel {
|
||||
model?: ModelRef
|
||||
}
|
||||
|
||||
export interface ThinkModeInput {
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
message: MessageWithModel
|
||||
}
|
||||
@@ -36,6 +36,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
const remindedSessions = new Set<string>()
|
||||
const interruptedSessions = new Set<string>()
|
||||
const errorSessions = new Set<string>()
|
||||
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
@@ -47,6 +48,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
if (detectInterrupt(props?.error)) {
|
||||
interruptedSessions.add(sessionID)
|
||||
}
|
||||
|
||||
// Cancel pending continuation if error occurs
|
||||
const timer = pendingTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pendingTimers.delete(sessionID)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -55,68 +63,78 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
// Wait for potential session.error events to be processed first
|
||||
await new Promise(resolve => setTimeout(resolve, 150))
|
||||
|
||||
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
||||
|
||||
interruptedSessions.delete(sessionID)
|
||||
errorSessions.delete(sessionID)
|
||||
|
||||
if (shouldBypass) {
|
||||
return
|
||||
// Cancel any existing timer to debounce
|
||||
const existingTimer = pendingTimers.get(sessionID)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
if (remindedSessions.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
// Schedule continuation check
|
||||
const timer = setTimeout(async () => {
|
||||
pendingTimers.delete(sessionID)
|
||||
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
todos = (response.data ?? response) as Todo[]
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
|
||||
|
||||
interruptedSessions.delete(sessionID)
|
||||
errorSessions.delete(sessionID)
|
||||
|
||||
if (!todos || todos.length === 0) {
|
||||
return
|
||||
}
|
||||
if (shouldBypass) {
|
||||
return
|
||||
}
|
||||
|
||||
const incomplete = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
if (remindedSessions.has(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (incomplete.length === 0) {
|
||||
return
|
||||
}
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
todos = (response.data ?? response) as Todo[]
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
remindedSessions.add(sessionID)
|
||||
if (!todos || todos.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check if abort occurred during the delay
|
||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
||||
remindedSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
const incomplete = todos.filter(
|
||||
(t) => t.status !== "completed" && t.status !== "cancelled"
|
||||
)
|
||||
|
||||
try {
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
} catch {
|
||||
remindedSessions.delete(sessionID)
|
||||
}
|
||||
if (incomplete.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
remindedSessions.add(sessionID)
|
||||
|
||||
// Re-check if abort occurred during the delay/fetch
|
||||
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
|
||||
remindedSessions.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
} catch {
|
||||
remindedSessions.delete(sessionID)
|
||||
}
|
||||
}, 200)
|
||||
|
||||
pendingTimers.set(sessionID, timer)
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
@@ -124,6 +142,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
if (sessionID && info?.role === "user") {
|
||||
remindedSessions.delete(sessionID)
|
||||
|
||||
// Cancel pending continuation on user interaction
|
||||
const timer = pendingTimers.get(sessionID)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pendingTimers.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +158,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
remindedSessions.delete(sessionInfo.id)
|
||||
interruptedSessions.delete(sessionInfo.id)
|
||||
errorSessions.delete(sessionInfo.id)
|
||||
|
||||
// Cancel pending continuation
|
||||
const timer = pendingTimers.get(sessionInfo.id)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
pendingTimers.delete(sessionInfo.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
206
src/index.ts
206
src/index.ts
@@ -7,91 +7,189 @@ import {
|
||||
createCommentCheckerHooks,
|
||||
createGrepOutputTruncatorHook,
|
||||
createDirectoryAgentsInjectorHook,
|
||||
createDirectoryReadmeInjectorHook,
|
||||
createEmptyTaskResponseDetectorHook,
|
||||
createThinkModeHook,
|
||||
createClaudeCodeHooksHook,
|
||||
createAnthropicAutoCompactHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "./features/claude-code-command-loader";
|
||||
import {
|
||||
loadUserSkillsAsCommands,
|
||||
loadProjectSkillsAsCommands,
|
||||
} from "./features/claude-code-skill-loader";
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "./features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
|
||||
import {
|
||||
setCurrentSession,
|
||||
setMainSession,
|
||||
getMainSessionID,
|
||||
getCurrentSessionTitle,
|
||||
} from "./features/claude-code-session-state";
|
||||
import { updateTerminalTitle } from "./features/terminal";
|
||||
import { builtinTools } from "./tools";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import { log } from "./shared/logger";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import * as os from "os";
|
||||
|
||||
function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
return null;
|
||||
}
|
||||
|
||||
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
|
||||
return result.data;
|
||||
}
|
||||
} catch (err) {
|
||||
log(`Error loading config from ${configPath}:`, err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function mergeConfigs(base: OhMyOpenCodeConfig, override: OhMyOpenCodeConfig): OhMyOpenCodeConfig {
|
||||
return {
|
||||
...base,
|
||||
...override,
|
||||
agents: override.agents !== undefined
|
||||
? { ...(base.agents ?? {}), ...override.agents }
|
||||
: base.agents,
|
||||
disabled_agents: [
|
||||
...new Set([...(base.disabled_agents ?? []), ...(override.disabled_agents ?? [])])
|
||||
],
|
||||
disabled_mcps: [
|
||||
...new Set([...(base.disabled_mcps ?? []), ...(override.disabled_mcps ?? [])])
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
const configPaths = [
|
||||
path.join(directory, "oh-my-opencode.json"),
|
||||
path.join(directory, ".oh-my-opencode.json"),
|
||||
// User-level config paths
|
||||
const userConfigPaths = [
|
||||
path.join(os.homedir(), ".config", "opencode", "oh-my-opencode.json"),
|
||||
];
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
// Project-level config paths (higher precedence)
|
||||
const projectConfigPaths = [
|
||||
path.join(directory, ".opencode", "oh-my-opencode.json"),
|
||||
];
|
||||
|
||||
if (!result.success) {
|
||||
console.error(
|
||||
`[oh-my-opencode] Config validation error in ${configPath}:`,
|
||||
);
|
||||
for (const issue of result.error.issues) {
|
||||
console.error(` - ${issue.path.join(".")}: ${issue.message}`);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, use defaults
|
||||
// Load user config first
|
||||
let config: OhMyOpenCodeConfig = {};
|
||||
for (const configPath of userConfigPaths) {
|
||||
const userConfig = loadConfigFromPath(configPath);
|
||||
if (userConfig) {
|
||||
config = userConfig;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
// Override with project config
|
||||
for (const configPath of projectConfigPaths) {
|
||||
const projectConfig = loadConfigFromPath(configPath);
|
||||
if (projectConfig) {
|
||||
config = mergeConfigs(config, projectConfig);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log("Final merged config", { agents: config.agents, disabled_agents: config.disabled_agents, disabled_mcps: config.disabled_mcps });
|
||||
return config;
|
||||
}
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const pluginConfig = loadPluginConfig(ctx.directory);
|
||||
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx);
|
||||
const commentChecker = createCommentCheckerHooks();
|
||||
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
|
||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
const directoryReadmeInjector = createDirectoryReadmeInjectorHook(ctx);
|
||||
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
||||
const thinkMode = createThinkModeHook();
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
|
||||
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
|
||||
|
||||
updateTerminalTitle({ sessionId: "main" });
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory);
|
||||
|
||||
let mainSessionID: string | undefined;
|
||||
let currentSessionID: string | undefined;
|
||||
let currentSessionTitle: string | undefined;
|
||||
|
||||
return {
|
||||
tool: builtinTools,
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output)
|
||||
},
|
||||
|
||||
config: async (config) => {
|
||||
const agents = createBuiltinAgents(
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents,
|
||||
);
|
||||
const userAgents = loadUserAgents();
|
||||
const projectAgents = loadProjectAgents();
|
||||
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...config.agent,
|
||||
...agents,
|
||||
};
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
};
|
||||
|
||||
const mcpResult = await loadMcpConfigs();
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
...mcpResult.servers,
|
||||
};
|
||||
|
||||
const userCommands = loadUserCommands();
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = config.command ?? {};
|
||||
const projectCommands = loadProjectCommands();
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
const userSkills = loadUserSkillsAsCommands();
|
||||
const projectSkills = loadProjectSkillsAsCommands();
|
||||
|
||||
config.command = {
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
};
|
||||
},
|
||||
|
||||
event: async (input) => {
|
||||
await claudeCodeHooks.event(input);
|
||||
await todoContinuationEnforcer(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await directoryAgentsInjector.event(input);
|
||||
await directoryReadmeInjector.event(input);
|
||||
await thinkMode.event(input);
|
||||
await anthropicAutoCompact.event(input);
|
||||
|
||||
const { event } = input;
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
@@ -101,14 +199,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined;
|
||||
if (!sessionInfo?.parentID) {
|
||||
mainSessionID = sessionInfo?.id;
|
||||
currentSessionID = sessionInfo?.id;
|
||||
currentSessionTitle = sessionInfo?.title;
|
||||
setMainSession(sessionInfo?.id);
|
||||
setCurrentSession(sessionInfo?.id, sessionInfo?.title);
|
||||
updateTerminalTitle({
|
||||
sessionId: currentSessionID || "main",
|
||||
sessionId: sessionInfo?.id || "main",
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
sessionTitle: sessionInfo?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -118,23 +215,21 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined;
|
||||
if (!sessionInfo?.parentID) {
|
||||
currentSessionID = sessionInfo?.id;
|
||||
currentSessionTitle = sessionInfo?.title;
|
||||
setCurrentSession(sessionInfo?.id, sessionInfo?.title);
|
||||
updateTerminalTitle({
|
||||
sessionId: currentSessionID || "main",
|
||||
sessionId: sessionInfo?.id || "main",
|
||||
status: "processing",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
sessionTitle: sessionInfo?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id === mainSessionID) {
|
||||
mainSessionID = undefined;
|
||||
currentSessionID = undefined;
|
||||
currentSessionTitle = undefined;
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined);
|
||||
setCurrentSession(undefined, undefined);
|
||||
updateTerminalTitle({
|
||||
sessionId: "main",
|
||||
status: "idle",
|
||||
@@ -156,7 +251,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const recovered =
|
||||
await sessionRecovery.handleSessionRecovery(messageInfo);
|
||||
|
||||
if (recovered && sessionID && sessionID === mainSessionID) {
|
||||
if (recovered && sessionID && sessionID === getMainSessionID()) {
|
||||
await ctx.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
@@ -167,56 +262,59 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
if (sessionID && sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: sessionID,
|
||||
status: "error",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
if (sessionID && sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: sessionID,
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||
await commentChecker["tool.execute.before"](input, output);
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
if (input.sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: input.sessionID,
|
||||
status: "tool",
|
||||
currentTool: input.tool,
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
await claudeCodeHooks["tool.execute.after"](input, output);
|
||||
await grepOutputTruncator["tool.execute.after"](input, output);
|
||||
await contextWindowMonitor["tool.execute.after"](input, output);
|
||||
await commentChecker["tool.execute.after"](input, output);
|
||||
await directoryAgentsInjector["tool.execute.after"](input, output);
|
||||
await directoryReadmeInjector["tool.execute.after"](input, output);
|
||||
await emptyTaskResponseDetector["tool.execute.after"](input, output);
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
if (input.sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: input.sessionID,
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
203
src/shared/command-executor.ts
Normal file
203
src/shared/command-executor.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { spawn } from "child_process"
|
||||
import { exec } from "child_process"
|
||||
import { promisify } from "util"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
if (customZshPath && existsSync(customZshPath)) {
|
||||
return customZshPath
|
||||
}
|
||||
for (const path of DEFAULT_ZSH_PATHS) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface CommandResult {
|
||||
exitCode: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
export interface ExecuteHookOptions {
|
||||
forceZsh?: boolean
|
||||
zshPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a hook command with stdin input
|
||||
*/
|
||||
export async function executeHookCommand(
|
||||
command: string,
|
||||
stdin: string,
|
||||
cwd: string,
|
||||
options?: ExecuteHookOptions
|
||||
): Promise<CommandResult> {
|
||||
const home = process.env.HOME ?? ""
|
||||
|
||||
let expandedCommand = command
|
||||
.replace(/^~(?=\/|$)/g, home)
|
||||
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
|
||||
|
||||
let finalCommand = expandedCommand
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = options.zshPath || findZshPath()
|
||||
if (zshPath) {
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.stdin?.write(stdin)
|
||||
proc.stdin?.end()
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({
|
||||
exitCode: 1,
|
||||
stderr: err.message,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a simple command and return output
|
||||
*/
|
||||
export async function executeCommand(command: string): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command)
|
||||
|
||||
const out = stdout?.toString().trim() ?? ""
|
||||
const err = stderr?.toString().trim() ?? ""
|
||||
|
||||
if (err) {
|
||||
if (out) {
|
||||
return `${out}\n[stderr: ${err}]`
|
||||
}
|
||||
return `[stderr: ${err}]`
|
||||
}
|
||||
|
||||
return out
|
||||
} catch (error: unknown) {
|
||||
const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string }
|
||||
const stdout = e?.stdout?.toString().trim() ?? ""
|
||||
const stderr = e?.stderr?.toString().trim() ?? ""
|
||||
const errMsg = stderr || e?.message || String(error)
|
||||
|
||||
if (stdout) {
|
||||
return `${stdout}\n[stderr: ${errMsg}]`
|
||||
}
|
||||
return `[stderr: ${errMsg}]`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and execute embedded commands in text (!`command`)
|
||||
*/
|
||||
interface CommandMatch {
|
||||
fullMatch: string
|
||||
command: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const COMMAND_PATTERN = /!`([^`]+)`/g
|
||||
|
||||
function findCommands(text: string): CommandMatch[] {
|
||||
const matches: CommandMatch[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
COMMAND_PATTERN.lastIndex = 0
|
||||
|
||||
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
command: match[1],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve embedded commands in text recursively
|
||||
*/
|
||||
export async function resolveCommandsInText(
|
||||
text: string,
|
||||
depth: number = 0,
|
||||
maxDepth: number = 3
|
||||
): Promise<string> {
|
||||
if (depth >= maxDepth) {
|
||||
return text
|
||||
}
|
||||
|
||||
const matches = findCommands(text)
|
||||
if (matches.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
const tasks = matches.map((m) => executeCommand(m.command))
|
||||
const results = await Promise.allSettled(tasks)
|
||||
|
||||
const replacements = new Map<string, string>()
|
||||
|
||||
matches.forEach((match, idx) => {
|
||||
const result = results[idx]
|
||||
if (result.status === "rejected") {
|
||||
replacements.set(
|
||||
match.fullMatch,
|
||||
`[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]`
|
||||
)
|
||||
} else {
|
||||
replacements.set(match.fullMatch, result.value)
|
||||
}
|
||||
})
|
||||
|
||||
let resolved = text
|
||||
for (const [pattern, replacement] of replacements.entries()) {
|
||||
resolved = resolved.split(pattern).join(replacement)
|
||||
}
|
||||
|
||||
if (findCommands(resolved).length > 0) {
|
||||
return resolveCommandsInText(resolved, depth + 1, maxDepth)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
85
src/shared/file-reference-resolver.ts
Normal file
85
src/shared/file-reference-resolver.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { existsSync, readFileSync, statSync } from "fs"
|
||||
import { join, isAbsolute } from "path"
|
||||
|
||||
interface FileMatch {
|
||||
fullMatch: string
|
||||
filePath: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const FILE_REFERENCE_PATTERN = /@([^\s@]+)/g
|
||||
|
||||
function findFileReferences(text: string): FileMatch[] {
|
||||
const matches: FileMatch[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
FILE_REFERENCE_PATTERN.lastIndex = 0
|
||||
|
||||
while ((match = FILE_REFERENCE_PATTERN.exec(text)) !== null) {
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
filePath: match[1],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function resolveFilePath(filePath: string, cwd: string): string {
|
||||
if (isAbsolute(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
return join(cwd, filePath)
|
||||
}
|
||||
|
||||
function readFileContent(resolvedPath: string): string {
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return `[file not found: ${resolvedPath}]`
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath)
|
||||
if (stat.isDirectory()) {
|
||||
return `[cannot read directory: ${resolvedPath}]`
|
||||
}
|
||||
|
||||
const content = readFileSync(resolvedPath, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
export async function resolveFileReferencesInText(
|
||||
text: string,
|
||||
cwd: string = process.cwd(),
|
||||
depth: number = 0,
|
||||
maxDepth: number = 3
|
||||
): Promise<string> {
|
||||
if (depth >= maxDepth) {
|
||||
return text
|
||||
}
|
||||
|
||||
const matches = findFileReferences(text)
|
||||
if (matches.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
const replacements = new Map<string, string>()
|
||||
|
||||
for (const match of matches) {
|
||||
const resolvedPath = resolveFilePath(match.filePath, cwd)
|
||||
const content = readFileContent(resolvedPath)
|
||||
replacements.set(match.fullMatch, content)
|
||||
}
|
||||
|
||||
let resolved = text
|
||||
for (const [pattern, replacement] of replacements.entries()) {
|
||||
resolved = resolved.split(pattern).join(replacement)
|
||||
}
|
||||
|
||||
if (findFileReferences(resolved).length > 0 && depth + 1 < maxDepth) {
|
||||
return resolveFileReferencesInText(resolved, cwd, depth + 1, maxDepth)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
34
src/shared/frontmatter.ts
Normal file
34
src/shared/frontmatter.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface FrontmatterResult<T = Record<string, string>> {
|
||||
data: T
|
||||
body: string
|
||||
}
|
||||
|
||||
export function parseFrontmatter<T = Record<string, string>>(
|
||||
content: string
|
||||
): FrontmatterResult<T> {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||
const match = content.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {} as T, body: content }
|
||||
}
|
||||
|
||||
const yamlContent = match[1]
|
||||
const body = match[2]
|
||||
|
||||
const data: Record<string, string | boolean> = {}
|
||||
for (const line of yamlContent.split("\n")) {
|
||||
const colonIndex = line.indexOf(":")
|
||||
if (colonIndex !== -1) {
|
||||
const key = line.slice(0, colonIndex).trim()
|
||||
let value: string | boolean = line.slice(colonIndex + 1).trim()
|
||||
|
||||
if (value === "true") value = true
|
||||
else if (value === "false") value = false
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return { data: data as T, body }
|
||||
}
|
||||
22
src/shared/hook-disabled.ts
Normal file
22
src/shared/hook-disabled.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { ClaudeHookEvent, PluginConfig } from "../hooks/claude-code-hooks/types"
|
||||
|
||||
export function isHookDisabled(
|
||||
config: PluginConfig,
|
||||
hookType: ClaudeHookEvent
|
||||
): boolean {
|
||||
const { disabledHooks } = config
|
||||
|
||||
if (disabledHooks === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (disabledHooks === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(disabledHooks)) {
|
||||
return disabledHooks.includes(hookType)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
9
src/shared/index.ts
Normal file
9
src/shared/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./frontmatter"
|
||||
export * from "./command-executor"
|
||||
export * from "./file-reference-resolver"
|
||||
export * from "./model-sanitizer"
|
||||
export * from "./logger"
|
||||
export * from "./snake-case"
|
||||
export * from "./tool-name"
|
||||
export * from "./pattern-matcher"
|
||||
export * from "./hook-disabled"
|
||||
20
src/shared/logger.ts
Normal file
20
src/shared/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Shared logging utility for the plugin
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
const logFile = path.join(os.tmpdir(), "oh-my-opencode.log")
|
||||
|
||||
export function log(message: string, data?: unknown): void {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
|
||||
fs.appendFileSync(logFile, logEntry)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return logFile
|
||||
}
|
||||
13
src/shared/model-sanitizer.ts
Normal file
13
src/shared/model-sanitizer.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Sanitizes model field from frontmatter.
|
||||
* Always returns undefined to let SDK use default model.
|
||||
*
|
||||
* Claude Code and OpenCode use different model ID formats,
|
||||
* so we ignore the model field and let OpenCode use its configured default.
|
||||
*
|
||||
* @param _model - Raw model value from frontmatter (ignored)
|
||||
* @returns Always undefined to inherit default model
|
||||
*/
|
||||
export function sanitizeModelField(_model: unknown): undefined {
|
||||
return undefined
|
||||
}
|
||||
29
src/shared/pattern-matcher.ts
Normal file
29
src/shared/pattern-matcher.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ClaudeHooksConfig, HookMatcher } from "../hooks/claude-code-hooks/types"
|
||||
|
||||
export function matchesToolMatcher(toolName: string, matcher: string): boolean {
|
||||
if (!matcher) {
|
||||
return true
|
||||
}
|
||||
const patterns = matcher.split("|").map((p) => p.trim())
|
||||
return patterns.some((p) => {
|
||||
if (p.includes("*")) {
|
||||
const regex = new RegExp(`^${p.replace(/\*/g, ".*")}$`, "i")
|
||||
return regex.test(toolName)
|
||||
}
|
||||
return p.toLowerCase() === toolName.toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
export function findMatchingHooks(
|
||||
config: ClaudeHooksConfig,
|
||||
eventName: keyof ClaudeHooksConfig,
|
||||
toolName?: string
|
||||
): HookMatcher[] {
|
||||
const hookMatchers = config[eventName]
|
||||
if (!hookMatchers) return []
|
||||
|
||||
return hookMatchers.filter((hookMatcher) => {
|
||||
if (!toolName) return true
|
||||
return matchesToolMatcher(toolName, hookMatcher.matcher)
|
||||
})
|
||||
}
|
||||
51
src/shared/snake-case.ts
Normal file
51
src/shared/snake-case.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export function camelToSnake(str: string): string {
|
||||
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
|
||||
}
|
||||
|
||||
export function snakeToCamel(str: string): string {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function objectToSnakeCase(
|
||||
obj: Record<string, unknown>,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const snakeKey = camelToSnake(key)
|
||||
if (deep && isPlainObject(value)) {
|
||||
result[snakeKey] = objectToSnakeCase(value, true)
|
||||
} else if (deep && Array.isArray(value)) {
|
||||
result[snakeKey] = value.map((item) =>
|
||||
isPlainObject(item) ? objectToSnakeCase(item, true) : item
|
||||
)
|
||||
} else {
|
||||
result[snakeKey] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function objectToCamelCase(
|
||||
obj: Record<string, unknown>,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
const camelKey = snakeToCamel(key)
|
||||
if (deep && isPlainObject(value)) {
|
||||
result[camelKey] = objectToCamelCase(value, true)
|
||||
} else if (deep && Array.isArray(value)) {
|
||||
result[camelKey] = value.map((item) =>
|
||||
isPlainObject(item) ? objectToCamelCase(item, true) : item
|
||||
)
|
||||
} else {
|
||||
result[camelKey] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
26
src/shared/tool-name.ts
Normal file
26
src/shared/tool-name.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
|
||||
webfetch: "WebFetch",
|
||||
websearch: "WebSearch",
|
||||
todoread: "TodoRead",
|
||||
todowrite: "TodoWrite",
|
||||
}
|
||||
|
||||
function toPascalCase(str: string): string {
|
||||
return str
|
||||
.split(/[-_\s]+/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("")
|
||||
}
|
||||
|
||||
export function transformToolName(toolName: string): string {
|
||||
const lower = toolName.toLowerCase()
|
||||
if (lower in SPECIAL_TOOL_MAPPINGS) {
|
||||
return SPECIAL_TOOL_MAPPINGS[lower]
|
||||
}
|
||||
|
||||
if (toolName.includes("-") || toolName.includes("_")) {
|
||||
return toPascalCase(toolName)
|
||||
}
|
||||
|
||||
return toolName.charAt(0).toUpperCase() + toolName.slice(1)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
|
||||
import { grep } from "./grep"
|
||||
import { glob } from "./glob"
|
||||
import { slashcommand } from "./slashcommand"
|
||||
import { skill } from "./skill"
|
||||
|
||||
export const builtinTools = {
|
||||
lsp_hover,
|
||||
@@ -36,4 +38,6 @@ export const builtinTools = {
|
||||
ast_grep_replace,
|
||||
grep,
|
||||
glob,
|
||||
slashcommand,
|
||||
skill,
|
||||
}
|
||||
|
||||
2
src/tools/skill/index.ts
Normal file
2
src/tools/skill/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { skill } from "./tools"
|
||||
323
src/tools/skill/tools.ts
Normal file
323
src/tools/skill/tools.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync, statSync, readlinkSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, resolve, basename } from "path"
|
||||
import { z } from "zod/v4"
|
||||
import { parseFrontmatter, resolveCommandsInText } from "../../shared"
|
||||
import { SkillFrontmatterSchema } from "./types"
|
||||
import type { SkillScope, SkillMetadata, SkillInfo, LoadedSkill, SkillFrontmatter } from "./types"
|
||||
|
||||
function parseSkillFrontmatter(data: Record<string, unknown>): SkillFrontmatter {
|
||||
return {
|
||||
name: typeof data.name === "string" ? data.name : "",
|
||||
description: typeof data.description === "string" ? data.description : "",
|
||||
license: typeof data.license === "string" ? data.license : undefined,
|
||||
"allowed-tools": Array.isArray(data["allowed-tools"]) ? data["allowed-tools"] : undefined,
|
||||
metadata:
|
||||
typeof data.metadata === "object" && data.metadata !== null
|
||||
? (data.metadata as Record<string, string>)
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function discoverSkillsFromDir(
|
||||
skillsDir: string,
|
||||
scope: SkillScope
|
||||
): Array<{ name: string; description: string; scope: SkillScope }> {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: Array<{ name: string; description: string; scope: SkillScope }> = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
let resolvedPath = skillPath
|
||||
try {
|
||||
const stats = statSync(skillPath, { throwIfNoEntry: false })
|
||||
if (stats?.isSymbolicLink()) {
|
||||
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
if (!existsSync(skillMdPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8")
|
||||
const { data } = parseFrontmatter(content)
|
||||
|
||||
skills.push({
|
||||
name: data.name || entry.name,
|
||||
description: data.description || "",
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
function discoverSkillsSync(): Array<{ name: string; description: string; scope: SkillScope }> {
|
||||
const userSkillsDir = join(homedir(), ".claude", "skills")
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
|
||||
const userSkills = discoverSkillsFromDir(userSkillsDir, "user")
|
||||
const projectSkills = discoverSkillsFromDir(projectSkillsDir, "project")
|
||||
|
||||
return [...projectSkills, ...userSkills]
|
||||
}
|
||||
|
||||
const availableSkills = discoverSkillsSync()
|
||||
const skillListForDescription = availableSkills
|
||||
.map((s) => `- ${s.name}: ${s.description} (${s.scope})`)
|
||||
.join("\n")
|
||||
|
||||
function resolveSymlink(skillPath: string): string {
|
||||
try {
|
||||
const stats = statSync(skillPath, { throwIfNoEntry: false })
|
||||
if (stats?.isSymbolicLink()) {
|
||||
return resolve(skillPath, "..", readlinkSync(skillPath))
|
||||
}
|
||||
return skillPath
|
||||
} catch {
|
||||
return skillPath
|
||||
}
|
||||
}
|
||||
|
||||
async function parseSkillMd(skillPath: string): Promise<SkillInfo | null> {
|
||||
const resolvedPath = resolveSymlink(skillPath)
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
|
||||
if (!existsSync(skillMdPath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
let content = readFileSync(skillMdPath, "utf-8")
|
||||
content = await resolveCommandsInText(content)
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
|
||||
const frontmatter = parseSkillFrontmatter(data)
|
||||
|
||||
const metadata: SkillMetadata = {
|
||||
name: frontmatter.name || basename(skillPath),
|
||||
description: frontmatter.description,
|
||||
license: frontmatter.license,
|
||||
allowedTools: frontmatter["allowed-tools"],
|
||||
metadata: frontmatter.metadata,
|
||||
}
|
||||
|
||||
const referencesDir = join(resolvedPath, "references")
|
||||
const scriptsDir = join(resolvedPath, "scripts")
|
||||
const assetsDir = join(resolvedPath, "assets")
|
||||
|
||||
const references = existsSync(referencesDir)
|
||||
? readdirSync(referencesDir).filter((f) => !f.startsWith("."))
|
||||
: []
|
||||
|
||||
const scripts = existsSync(scriptsDir)
|
||||
? readdirSync(scriptsDir).filter((f) => !f.startsWith(".") && !f.startsWith("__"))
|
||||
: []
|
||||
|
||||
const assets = existsSync(assetsDir)
|
||||
? readdirSync(assetsDir).filter((f) => !f.startsWith("."))
|
||||
: []
|
||||
|
||||
return {
|
||||
name: metadata.name,
|
||||
path: resolvedPath,
|
||||
basePath: resolvedPath,
|
||||
metadata,
|
||||
content: body,
|
||||
references,
|
||||
scripts,
|
||||
assets,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSkillsFromDirAsync(skillsDir: string): Promise<SkillInfo[]> {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: SkillInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const skillInfo = await parseSkillMd(skillPath)
|
||||
if (skillInfo) {
|
||||
skills.push(skillInfo)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
async function discoverSkills(): Promise<SkillInfo[]> {
|
||||
const userSkillsDir = join(homedir(), ".claude", "skills")
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
|
||||
const userSkills = await discoverSkillsFromDirAsync(userSkillsDir)
|
||||
const projectSkills = await discoverSkillsFromDirAsync(projectSkillsDir)
|
||||
|
||||
return [...projectSkills, ...userSkills]
|
||||
}
|
||||
|
||||
function findMatchingSkills(skills: SkillInfo[], query: string): SkillInfo[] {
|
||||
const queryLower = query.toLowerCase()
|
||||
const queryTerms = queryLower.split(/\s+/).filter(Boolean)
|
||||
|
||||
return skills
|
||||
.map((skill) => {
|
||||
let score = 0
|
||||
const nameLower = skill.metadata.name.toLowerCase()
|
||||
const descLower = skill.metadata.description.toLowerCase()
|
||||
|
||||
if (nameLower === queryLower) score += 100
|
||||
if (nameLower.includes(queryLower)) score += 50
|
||||
|
||||
for (const term of queryTerms) {
|
||||
if (nameLower.includes(term)) score += 20
|
||||
if (descLower.includes(term)) score += 10
|
||||
}
|
||||
|
||||
return { skill, score }
|
||||
})
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.map(({ skill }) => skill)
|
||||
}
|
||||
|
||||
async function loadSkillWithReferences(
|
||||
skill: SkillInfo,
|
||||
includeRefs: boolean
|
||||
): Promise<LoadedSkill> {
|
||||
const referencesLoaded: Array<{ path: string; content: string }> = []
|
||||
|
||||
if (includeRefs && skill.references.length > 0) {
|
||||
for (const ref of skill.references) {
|
||||
const refPath = join(skill.path, "references", ref)
|
||||
try {
|
||||
let content = readFileSync(refPath, "utf-8")
|
||||
content = await resolveCommandsInText(content)
|
||||
referencesLoaded.push({ path: ref, content })
|
||||
} catch {
|
||||
// Skip unreadable references
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: skill.name,
|
||||
metadata: skill.metadata,
|
||||
basePath: skill.basePath,
|
||||
body: skill.content,
|
||||
referencesLoaded,
|
||||
}
|
||||
}
|
||||
|
||||
function formatSkillList(skills: SkillInfo[]): string {
|
||||
if (skills.length === 0) {
|
||||
return "No skills found in ~/.claude/skills/"
|
||||
}
|
||||
|
||||
const lines = ["# Available Skills\n"]
|
||||
|
||||
for (const skill of skills) {
|
||||
lines.push(`- **${skill.metadata.name}**: ${skill.metadata.description || "(no description)"}`)
|
||||
}
|
||||
|
||||
lines.push(`\n**Total**: ${skills.length} skills`)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
function formatLoadedSkills(loadedSkills: LoadedSkill[]): string {
|
||||
if (loadedSkills.length === 0) {
|
||||
return "No skills loaded."
|
||||
}
|
||||
|
||||
const skill = loadedSkills[0]
|
||||
const sections: string[] = []
|
||||
|
||||
sections.push(`Base directory for this skill: ${skill.basePath}/`)
|
||||
sections.push("")
|
||||
sections.push(skill.body.trim())
|
||||
|
||||
if (skill.referencesLoaded.length > 0) {
|
||||
sections.push("\n---\n### Loaded References\n")
|
||||
for (const ref of skill.referencesLoaded) {
|
||||
sections.push(`#### ${ref.path}\n`)
|
||||
sections.push("```")
|
||||
sections.push(ref.content.trim())
|
||||
sections.push("```\n")
|
||||
}
|
||||
}
|
||||
|
||||
sections.push(`\n---\n**Launched skill**: ${skill.metadata.name}`)
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
export const skill = tool({
|
||||
description: `Execute a skill within the main conversation.
|
||||
|
||||
When you invoke a skill, the skill's prompt will expand and provide detailed instructions on how to complete the task.
|
||||
|
||||
Available Skills:
|
||||
${skillListForDescription}`,
|
||||
|
||||
args: {
|
||||
skill: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"The skill name or search query to find and load. Can be exact skill name (e.g., 'python-programmer') or keywords (e.g., 'python', 'plan')."
|
||||
),
|
||||
},
|
||||
|
||||
async execute(args) {
|
||||
const skills = await discoverSkills()
|
||||
|
||||
if (!args.skill) {
|
||||
return formatSkillList(skills) + "\n\nProvide a skill name to load."
|
||||
}
|
||||
|
||||
const matchingSkills = findMatchingSkills(skills, args.skill)
|
||||
|
||||
if (matchingSkills.length === 0) {
|
||||
return (
|
||||
`No skills found matching "${args.skill}".\n\n` +
|
||||
formatSkillList(skills) +
|
||||
"\n\nTry a different skill name."
|
||||
)
|
||||
}
|
||||
|
||||
const loadedSkills: LoadedSkill[] = []
|
||||
|
||||
for (const skillInfo of matchingSkills.slice(0, 3)) {
|
||||
const loaded = await loadSkillWithReferences(skillInfo, true)
|
||||
loadedSkills.push(loaded)
|
||||
}
|
||||
|
||||
return formatLoadedSkills(loadedSkills)
|
||||
},
|
||||
})
|
||||
47
src/tools/skill/types.ts
Normal file
47
src/tools/skill/types.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from "zod/v4"
|
||||
|
||||
export type SkillScope = "user" | "project"
|
||||
|
||||
/**
|
||||
* Zod schema for skill frontmatter validation
|
||||
* Following Anthropic Agent Skills Specification v1.0
|
||||
*/
|
||||
export const SkillFrontmatterSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, "Name must be lowercase alphanumeric with hyphens only")
|
||||
.min(1, "Name cannot be empty"),
|
||||
description: z.string().min(20, "Description must be at least 20 characters for discoverability"),
|
||||
license: z.string().optional(),
|
||||
"allowed-tools": z.array(z.string()).optional(),
|
||||
metadata: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
|
||||
export type SkillFrontmatter = z.infer<typeof SkillFrontmatterSchema>
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string
|
||||
description: string
|
||||
license?: string
|
||||
allowedTools?: string[]
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string
|
||||
path: string
|
||||
basePath: string
|
||||
metadata: SkillMetadata
|
||||
content: string
|
||||
references: string[]
|
||||
scripts: string[]
|
||||
assets: string[]
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
metadata: SkillMetadata
|
||||
basePath: string
|
||||
body: string
|
||||
referencesLoaded: Array<{ path: string; content: string }>
|
||||
}
|
||||
2
src/tools/slashcommand/index.ts
Normal file
2
src/tools/slashcommand/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export { slashcommand } from "./tools"
|
||||
202
src/tools/slashcommand/tools.ts
Normal file
202
src/tools/slashcommand/tools.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { tool } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { parseFrontmatter, resolveCommandsInText, resolveFileReferencesInText, sanitizeModelField } from "../../shared"
|
||||
import type { CommandScope, CommandMetadata, CommandInfo } from "./types"
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: CommandInfo[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
if (!entry.name.endsWith(".md")) continue
|
||||
if (!entry.isFile()) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter(content)
|
||||
|
||||
const metadata: CommandMetadata = {
|
||||
name: commandName,
|
||||
description: data.description || "",
|
||||
argumentHint: data["argument-hint"],
|
||||
model: sanitizeModelField(data.model),
|
||||
agent: data.agent,
|
||||
subtask: Boolean(data.subtask),
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
metadata,
|
||||
content: body,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function discoverCommandsSync(): CommandInfo[] {
|
||||
const userCommandsDir = join(homedir(), ".claude", "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
|
||||
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
|
||||
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
|
||||
return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands]
|
||||
}
|
||||
|
||||
const availableCommands = discoverCommandsSync()
|
||||
const commandListForDescription = availableCommands
|
||||
.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[] = []
|
||||
|
||||
sections.push(`# /${cmd.name} Command\n`)
|
||||
|
||||
if (cmd.metadata.description) {
|
||||
sections.push(`**Description**: ${cmd.metadata.description}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.argumentHint) {
|
||||
sections.push(`**Usage**: /${cmd.name} ${cmd.metadata.argumentHint}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.model) {
|
||||
sections.push(`**Model**: ${cmd.metadata.model}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.agent) {
|
||||
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
|
||||
}
|
||||
|
||||
if (cmd.metadata.subtask) {
|
||||
sections.push(`**Subtask**: true\n`)
|
||||
}
|
||||
|
||||
sections.push(`**Scope**: ${cmd.scope}\n`)
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
const commandDir = dirname(cmd.path)
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
return sections.join("\n")
|
||||
}
|
||||
|
||||
function formatCommandList(commands: CommandInfo[]): string {
|
||||
if (commands.length === 0) {
|
||||
return "No commands found."
|
||||
}
|
||||
|
||||
const lines = ["# Available Commands\n"]
|
||||
|
||||
for (const cmd of commands) {
|
||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||
lines.push(
|
||||
`- **/${cmd.name}${hint}**: ${cmd.metadata.description || "(no description)"} (${cmd.scope})`
|
||||
)
|
||||
}
|
||||
|
||||
lines.push(`\n**Total**: ${commands.length} commands`)
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export const slashcommand = tool({
|
||||
description: `Execute a slash command within the main conversation.
|
||||
|
||||
When you use this tool, the slash command gets expanded to a full prompt that provides detailed instructions on how to complete the task.
|
||||
|
||||
How slash commands work:
|
||||
- Invoke commands using this tool with the command name (without arguments)
|
||||
- The command's prompt will expand and provide detailed instructions
|
||||
- Arguments from user input should be passed separately
|
||||
|
||||
Important:
|
||||
- Only use commands listed in Available Commands below
|
||||
- Do not invoke a command that is already running
|
||||
- **CRITICAL**: When user's message starts with '/' (e.g., "/commit", "/plan"), you MUST immediately invoke this tool with that command. Do NOT attempt to handle the command manually.
|
||||
|
||||
Commands are loaded from (priority order, highest wins):
|
||||
- .opencode/command/ (opencode-project - OpenCode project-specific commands)
|
||||
- ./.claude/commands/ (project - Claude Code project-specific commands)
|
||||
- ~/.config/opencode/command/ (opencode - OpenCode global commands)
|
||||
- ~/.claude/commands/ (user - Claude Code global commands)
|
||||
|
||||
Each command is a markdown file with:
|
||||
- YAML frontmatter: description, argument-hint, model, agent, subtask (optional)
|
||||
- Markdown body: The command instructions/prompt
|
||||
- File references: @path/to/file (relative to command file location)
|
||||
- Shell injection: \`!\`command\`\` (executes and injects output)
|
||||
|
||||
Available Commands:
|
||||
${commandListForDescription}`,
|
||||
|
||||
args: {
|
||||
command: tool.schema
|
||||
.string()
|
||||
.describe(
|
||||
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
|
||||
),
|
||||
},
|
||||
|
||||
async execute(args) {
|
||||
const commands = discoverCommandsSync()
|
||||
|
||||
if (!args.command) {
|
||||
return formatCommandList(commands) + "\n\nProvide a command name to execute."
|
||||
}
|
||||
|
||||
const cmdName = args.command.replace(/^\//, "")
|
||||
|
||||
const exactMatch = commands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
|
||||
)
|
||||
|
||||
if (exactMatch) {
|
||||
return await formatLoadedCommand(exactMatch)
|
||||
}
|
||||
|
||||
const partialMatches = commands.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(commands)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
`Command "/${cmdName}" not found.\n\n` +
|
||||
formatCommandList(commands) +
|
||||
"\n\nTry a different command name."
|
||||
)
|
||||
},
|
||||
})
|
||||
18
src/tools/slashcommand/types.ts
Normal file
18
src/tools/slashcommand/types.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface CommandMetadata {
|
||||
name: string
|
||||
description: string
|
||||
argumentHint?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
export interface CommandInfo {
|
||||
name: string
|
||||
path: string
|
||||
metadata: CommandMetadata
|
||||
content: string
|
||||
scope: CommandScope
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
## Root Level Rules
|
||||
|
||||
- Root rule 1
|
||||
- Root rule 2
|
||||
@@ -1,3 +0,0 @@
|
||||
export const config = {
|
||||
strict: true
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
## Nested Level Rules
|
||||
|
||||
- Nested rule 1 (더 specific)
|
||||
- Nested rule 2
|
||||
@@ -1 +0,0 @@
|
||||
export const deep = true
|
||||
@@ -1,3 +0,0 @@
|
||||
export function greet(name: string): string {
|
||||
return `Hello, ${name}!`
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
id: test-rule
|
||||
message: Test rule
|
||||
severity: info
|
||||
language: JavaScript
|
||||
rule:
|
||||
pattern: console
|
||||
Reference in New Issue
Block a user