Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
a711d58289 | ||
|
|
431ec14991 | ||
|
|
62cae8114d | ||
|
|
e6eafe267a | ||
|
|
e4ef832405 | ||
|
|
ef6d67645e | ||
|
|
227d93f106 | ||
|
|
edff922afb | ||
|
|
45bdcf3580 | ||
|
|
b07dd22093 | ||
|
|
c7d29fea48 | ||
|
|
55675497a5 | ||
|
|
ae2d347d81 | ||
|
|
2683de825a | ||
|
|
0b5c8250ca | ||
|
|
66fcd8570b | ||
|
|
5cd3f0cbf2 | ||
|
|
9a9512b705 | ||
|
|
6ece7476ef | ||
|
|
9ed23d4037 | ||
|
|
79b791117a | ||
|
|
4e328a937c | ||
|
|
a500f0c9ad | ||
|
|
16806da615 | ||
|
|
c5f651c0a9 | ||
|
|
ed3d7a55f4 | ||
|
|
b77dd2fcdf | ||
|
|
64b3564760 | ||
|
|
0df7e9b10b | ||
|
|
aa35f2eab6 | ||
|
|
64db980803 | ||
|
|
b86346a79d | ||
|
|
4debb57402 | ||
|
|
a763db61cf | ||
|
|
341e5a959d | ||
|
|
bac304c035 | ||
|
|
1aaeefac0e | ||
|
|
dda7b4f56d | ||
|
|
a287e59262 | ||
|
|
80fe3ae612 | ||
|
|
b045f6918e | ||
|
|
725ec9b91d | ||
|
|
1f717a76be | ||
|
|
3bcb869a5d | ||
|
|
54e13e4330 | ||
|
|
1780e2971d | ||
|
|
ded97701b8 | ||
|
|
316cdc1a62 | ||
|
|
f19cd8fc71 | ||
|
|
181194ae3c | ||
|
|
b8f5599e61 | ||
|
|
ea2b09ebb9 | ||
|
|
143dd8aaa9 | ||
|
|
36169c83fb | ||
|
|
bf9f033635 | ||
|
|
de2f9dfc86 | ||
|
|
5f5218ccae | ||
|
|
41d3817910 | ||
|
|
a119429ebe | ||
|
|
9bed2dd79a | ||
|
|
4c1d534093 | ||
|
|
a04234eaab | ||
|
|
6d0e4c49c2 | ||
|
|
8f39575264 | ||
|
|
2464473731 | ||
|
|
1b0a8adb2b | ||
|
|
4b7a4b04d0 | ||
|
|
f6f2fad1fc | ||
|
|
3ca9693de0 | ||
|
|
e2078763a6 | ||
|
|
5121d0eed2 | ||
|
|
973caf9b7d | ||
|
|
b277d96de8 | ||
|
|
2c223d96a1 | ||
|
|
695aeafb86 | ||
|
|
812c544bfa | ||
|
|
edf0e7d946 | ||
|
|
baa7fadab1 | ||
|
|
a06bbeb9ee | ||
|
|
f3a92db203 | ||
|
|
fd6e230889 | ||
|
|
50ea492065 | ||
|
|
f5f2053b7a | ||
|
|
6c16baea9a | ||
|
|
2ad7e193fd | ||
|
|
d62f1dd207 | ||
|
|
aff7cad615 | ||
|
|
e021ec954a | ||
|
|
1390970973 | ||
|
|
a72bfe5c02 | ||
|
|
f10c15d83d |
24
AGENTS.md
24
AGENTS.md
@@ -84,9 +84,31 @@ bun run build
|
||||
bun run rebuild
|
||||
```
|
||||
|
||||
## DEPLOYMENT
|
||||
|
||||
**배포는 GitHub Actions workflow_dispatch로만 진행**
|
||||
|
||||
1. package.json 버전은 수정하지 않음 (워크플로우에서 자동 bump)
|
||||
2. 변경사항 커밋 & 푸시
|
||||
3. GitHub Actions에서 `publish` 워크플로우 수동 실행
|
||||
- `bump`: major | minor | patch 선택
|
||||
- `version`: (선택) 특정 버전 지정 가능
|
||||
|
||||
```bash
|
||||
# 워크플로우 실행 (CLI)
|
||||
gh workflow run publish -f bump=patch
|
||||
|
||||
# 워크플로우 상태 확인
|
||||
gh run list --workflow=publish
|
||||
```
|
||||
|
||||
**주의사항**:
|
||||
- `bun publish` 직접 실행 금지 (OIDC provenance 문제)
|
||||
- 로컬에서 버전 bump 하지 말 것
|
||||
|
||||
## NOTES
|
||||
|
||||
- **No tests**: Test framework not configured
|
||||
- **No CI/CD**: GitHub workflows not present
|
||||
- **CI/CD**: GitHub Actions publish workflow 사용
|
||||
- **Version requirement**: OpenCode >= 1.0.132 (earlier versions have config bugs)
|
||||
- **Multi-language docs**: README.md, README.en.md, README.ko.md
|
||||
|
||||
191
README.en.md
191
README.en.md
@@ -1,191 +0,0 @@
|
||||
English | [한국어](README.ko.md)
|
||||
|
||||
## Contents
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [Installation](#installation)
|
||||
- [Configuration](#configuration)
|
||||
- [Disable specific MCPs](#disable-specific-mcps)
|
||||
- [TL;DR](#tldr)
|
||||
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Features](#features)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [Built-in MCPs](#built-in-mcps)
|
||||
- [Other Features](#other-features)
|
||||
- [Author's Note](#authors-note)
|
||||
- [Warnings](#warnings)
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## Installation
|
||||
|
||||
Add to `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure Oh My OpenCode by creating a `oh-my-opencode.json` (or `.oh-my-opencode.json`) file in your project root.
|
||||
|
||||
### JSON Schema Support
|
||||
|
||||
For autocompletion and validation in VS Code (or other editors), add the `$schema` property to your configuration file.
|
||||
|
||||
**Using Remote Schema (Recommended):**
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json",
|
||||
"disabled_mcps": ["context7"]
|
||||
}
|
||||
```
|
||||
|
||||
**Using Local Schema:**
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oh-my-opencode/dist/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
### Disable specific MCPs
|
||||
|
||||
If you want to disable specific built-in MCPs, you can use the `disabled_mcps` option.
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Model Setup Required**
|
||||
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
|
||||
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
|
||||
- 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.**
|
||||
- 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.
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
OpenCode is limitlessly extensible and customizable. Zero screen flicker.
|
||||
[LSP](https://opencode.ai/docs/lsp/), [linters, formatters](https://opencode.ai/docs/formatters/)? Automatic and fully configurable.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Consider this a superior AmpCode, a superior Claude Code, or simply a specialized distribution.
|
||||
|
||||
I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI or VibeProxy. Employ the best LLMs from frontier labs. You are in command.
|
||||
|
||||
**Note**: This setup is highly opinionated. It represents the generic component of my personal configuration, so it evolves constantly. I have spent tokens worth $20,000 just for my personal programming usages, and this plugin represents the apex of that experience. You simply inherit the best. If you have superior ideas, PRs are welcome.
|
||||
|
||||
## Features
|
||||
|
||||
### Hooks
|
||||
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
|
||||
### 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.
|
||||
- **librarian** (`anthropic/claude-haiku-4-5`): Multi-repo analysis, documentation lookup, and implementation examples. Haiku is chosen for its speed, competence, excellent tool usage, and cost-efficiency. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`): Fast exploration and pattern matching. Claude Code uses Haiku; we use Grok. It is currently free, blazing fast, and intelligent enough for file traversal. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): A technical writing expert. Gemini is a wordsmith; it writes prose that flows naturally.
|
||||
|
||||
### Tools
|
||||
|
||||
#### Built-in LSP Tools
|
||||
|
||||
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis. Oh My OpenCode equips you with navigation and refactoring tools matching the same specification.
|
||||
|
||||
- **lsp_hover**: Get type info, docs, signatures at position
|
||||
- **lsp_goto_definition**: Jump to symbol definition
|
||||
- **lsp_find_references**: Find all usages across workspace
|
||||
- **lsp_document_symbols**: Get file's symbol outline
|
||||
- **lsp_workspace_symbols**: Search symbols by name across project
|
||||
- **lsp_diagnostics**: Get errors/warnings before build
|
||||
- **lsp_servers**: List available LSP servers
|
||||
- **lsp_prepare_rename**: Validate rename operation
|
||||
- **lsp_rename**: Rename symbol across workspace
|
||||
- **lsp_code_actions**: Get available quick fixes/refactorings
|
||||
- **lsp_code_action_resolve**: Apply a code action
|
||||
|
||||
#### Built-in AST-Grep Tools
|
||||
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
|
||||
#### Safe Grep
|
||||
|
||||
- **safe_grep**: Content search with safety limits (5min timeout, 10MB output).
|
||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||
- `safe_grep` enforces strict limits.
|
||||
- **Note**: Default `grep` is disabled to prevent Agent confusion. `safe_grep` delivers full `grep` functionality with safety assurance.
|
||||
|
||||
#### Built-in MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
|
||||
- **context7**: Library documentation lookup. Fetches up-to-date documentation for any library to assist with accurate coding.
|
||||
|
||||
### Other Features
|
||||
|
||||
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
|
||||
|
||||
## Author's Note
|
||||
|
||||
Install Oh My OpenCode. Do not waste time configuring OpenCode from scratch.
|
||||
I have resolved the friction so you don't have to. The answers are in this plugin. If OpenCode is Arch Linux, Oh My OpenCode is [Omarchy](https://omarchy.org/).
|
||||
|
||||
Enjoy the multi-model stability and rich feature set that other harnesses promise but fail to deliver.
|
||||
I will continue testing and updating here. I am the primary user of this project.
|
||||
|
||||
- Who possesses the best raw logic?
|
||||
- Who is the debugging god?
|
||||
- Who writes the best prose?
|
||||
- Who dominates frontend?
|
||||
- Who owns backend?
|
||||
- Which model is fastest for daily driving?
|
||||
- What new features are other harnesses shipping?
|
||||
|
||||
Do not overthink it. I have done the thinking. I will integrate the best practices. I will update this.
|
||||
If this sounds arrogant and you have a superior solution, send a PR. You are welcome.
|
||||
|
||||
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.
|
||||
## 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.
|
||||
- [The fix](https://github.com/sst/opencode/pull/5040) was merged after 1.0.132, so use a newer version.
|
||||
279
README.ko.md
279
README.ko.md
@@ -1,20 +1,23 @@
|
||||
[English](README.en.md) | 한국어
|
||||
[English](README.md) | 한국어
|
||||
|
||||
## 목차
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [설치](#설치)
|
||||
- [세 줄 요약](#세-줄-요약)
|
||||
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [설치](#설치)
|
||||
- [LLM Agent를 위한 안내](#llm-agent를-위한-안내)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [기능](#기능)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [내장 LSP Tools](#내장-lsp-tools)
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [Grep](#grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [Claude Code 호환성](#claude-code-호환성)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [설정](#설정)
|
||||
- [작성자의 노트](#작성자의-노트)
|
||||
- [주의](#주의)
|
||||
|
||||
@@ -33,6 +36,17 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
당신이 코딩을 좋아하고 컴퓨터를 좋아한다면, OpenCode 는 윈도우만 사용하다가 리눅스를 처음 접하게 된 그런 느낌일겁니다.
|
||||
그렇지 않은 당신도 약간의 시간을 투자해서 당신의 실력과 생산성을 몇배로 부스트하세요.
|
||||
|
||||
## 세 줄 요약
|
||||
|
||||
- **모델 설정이 필요합니다**
|
||||
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
|
||||
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
|
||||
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
|
||||
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
|
||||
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
|
||||
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
|
||||
|
||||
## 설치
|
||||
|
||||
`~/.config/opencode/opencode.json`에 추가:
|
||||
@@ -45,16 +59,56 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
|
||||
}
|
||||
```
|
||||
|
||||
## 세 줄 요약
|
||||
## LLM Agent를 위한 안내
|
||||
|
||||
- **모델 설정이 필요합니다**
|
||||
- 이 플러그인은 [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, Anthropic 의 모델을 사용합니다.
|
||||
- Anthropic 모델들을 사용하기 위해 [OpenCode 의 내장 Claude Code Max Plan 로그인 기능](https://opencode.ai/docs/providers/#anthropic)을 사용하세요. (Anthropic 플랜은 유료입니다)
|
||||
- OpenAI 모델들을 사용하기 위해 이 [OpenCode-OpenAI-Codex-Auth 플러그인](https://github.com/numman-ali/opencode-openai-codex-auth)을 추천합니다. (OpenAI 플랜은 유료입니다)
|
||||
- Gemini, GPT 를 위해 [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) 또는 [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI)를 추천합니다. (Gemini 는 구독하지 않아도 사용 할 수 있습니다)
|
||||
- **사실 밑의 내용은 읽지 않아도 됩니다. 그냥 설치하시면 됩니다. 모델 설정하고나면 추가로 공부하거나 알 필요가 없고, 설치하고 나면 그냥 더 좋아집니다.**
|
||||
- **이 문서도 그냥 Claude Code, OpenCode, Cursor에 붙여넣고, 설치하고, 사용자의 개입이 필요할 때 알려달라고 프롬프팅하세요. 그럼 그냥 깔립니다.**
|
||||
- 당신이 LLM Agent 라면, 필요한 플러그인과 의존성을 설치하고, tmux 를 통해 opencode auth login 을 실행하여 사용자의 구성을 도와주세요.
|
||||
<details>
|
||||
<summary>LLM Agent를 위한 안내 (클릭하여 펼치기)</summary>
|
||||
|
||||
당신이 사용자의 oh-my-opencode 설정을 돕는 LLM Agent라면, 다음 단계를 따르세요:
|
||||
|
||||
### 1단계: OpenCode 설치 확인
|
||||
|
||||
```bash
|
||||
if command -v opencode &> /dev/null; then
|
||||
echo "OpenCode $(opencode --version) 설치됨"
|
||||
else
|
||||
echo "OpenCode가 설치되어 있지 않습니다. 먼저 설치해주세요."
|
||||
echo "참고: https://opencode.ai/docs"
|
||||
fi
|
||||
```
|
||||
|
||||
OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://opencode.ai/docs)를 안내하세요.
|
||||
|
||||
### 2단계: oh-my-opencode 플러그인 설정
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode
|
||||
|
||||
if [ -f ~/.config/opencode/opencode.json ]; then
|
||||
# 기존 설정 파일이 있는 경우: jq 또는 python으로 plugin 추가
|
||||
if command -v jq &> /dev/null; then
|
||||
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
else
|
||||
echo "~/.config/opencode/opencode.json의 plugin 배열에 \"oh-my-opencode\"를 추가하세요"
|
||||
fi
|
||||
else
|
||||
# 새 설정 파일 생성
|
||||
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
|
||||
### 3단계: 설정 확인
|
||||
|
||||
```bash
|
||||
opencode --version # 1.0.132 이상이어야 함
|
||||
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
|
||||
```
|
||||
|
||||
> **참고**: 모델 인증이 필요하면, `opencode` 실행 후 `/connect` 명령어를 사용하거나, tmux를 통해 `opencode auth login`을 안내하세요.
|
||||
|
||||
</details>
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
@@ -83,7 +137,27 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
|
||||
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
|
||||
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
|
||||
|
||||
### Agents
|
||||
|
||||
@@ -93,10 +167,22 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
|
||||
|
||||
각 에이전트는 메인 에이전트가 알아서 호출하지만, 명시적으로 요청할 수도 있습니다:
|
||||
|
||||
```
|
||||
@oracle 한테 이 부분 설계 고민하고서 아키텍쳐 제안을 부탁해줘
|
||||
@librarian 한테 이 부분 어떻게 구현돼있길래 자꾸 안에서 동작이 바뀌는지 알려달라고 해줘
|
||||
@explore 한테 이 기능 정책 알려달라고 해줘
|
||||
```
|
||||
|
||||
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
|
||||
|
||||
### Tools
|
||||
|
||||
#### 내장 LSP Tools
|
||||
|
||||
당신이 에디터에서 사용하는 그 기능을 다른 에이전트들은 사용하지 못합니다. Oh My OpenCode 는 당신만의 그 도구를 LLM Agent 에게 쥐어줍니다. 리팩토링하고, 탐색하고, 분석하는 모든 작업을 OpenCode 의 설정값을 그대로 사용하여 지원합니다.
|
||||
|
||||
[OpenCode 는 LSP 를 제공하지만](https://opencode.ai/docs/lsp/), 오로지 분석용으로만 제공합니다. 탐색과 리팩토링을 위한 도구는 OpenCode 와 동일한 스펙과 설정으로 Oh My OpenCode 가 제공합니다.
|
||||
|
||||
- **lsp_hover**: 위치의 타입 정보, 문서, 시그니처 가져오기
|
||||
@@ -115,18 +201,175 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
|
||||
#### Safe Grep
|
||||
- **safe_grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한).
|
||||
#### Grep
|
||||
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
|
||||
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
||||
- safe_grep 은 timeout 과 더 엄격한 출력 제한을 적용합니다.
|
||||
- **주의**: 기본 grep 도구는 Agent 를 햇갈리게 하지 않기 위해 비활성화됩니다. 그러나 SafeGrep 은 Grep 이 제공하는 모든 기능을 제공합니다.
|
||||
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
|
||||
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
|
||||
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
|
||||
|
||||
#### 내장 MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
|
||||
- **context7**: 라이브러리 문서 조회. 정확한 코딩을 위해 최신 라이브러리 문서를 가져옵니다.
|
||||
|
||||
필요 없다면 `oh-my-opencode.json`에서 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
### 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**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
|
||||
|
||||
## 설정
|
||||
|
||||
설정 파일 위치 (우선순위 순):
|
||||
1. `.opencode/oh-my-opencode.json` (프로젝트)
|
||||
2. `~/.config/opencode/oh-my-opencode.json` (사용자)
|
||||
|
||||
Schema 자동 완성이 지원됩니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
### Agents
|
||||
|
||||
내장 에이전트 설정을 오버라이드할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"temperature": 0.5
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"disable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
또는 `disabled_agents`로 비활성화할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_agents": ["oracle", "frontend-ui-ux-engineer"]
|
||||
}
|
||||
```
|
||||
|
||||
사용 가능한 에이전트: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
|
||||
### MCPs
|
||||
|
||||
내장된 MCP를 비활성화합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
더 자세한 내용은 [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers)를 참조하세요.
|
||||
|
||||
### LSP
|
||||
|
||||
Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액션)만을 위한 것**입니다. 분석용 LSP는 OpenCode 자체에서 처리합니다.
|
||||
|
||||
`lsp` 옵션을 통해 LSP 서버를 설정합니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"priority": 10
|
||||
},
|
||||
"pylsp": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
각 서버는 다음을 지원합니다: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
## 작성자의 노트
|
||||
|
||||
@@ -153,5 +396,3 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
|
||||
|
||||
- [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) 혹은 이것보다 낮은 버전을 사용중이라면, OpenCode 의 버그로 인해 제대로 구성이 되지 않을 수 있습니다.
|
||||
- [이를 고치는 PR 이 1.0.132 배포 이후에 병합되었으므로](https://github.com/sst/opencode/pull/5040) 이 변경사항이 포함된 최신 버전을 사용해주세요.
|
||||
|
||||
|
||||
|
||||
396
README.md
Normal file
396
README.md
Normal file
@@ -0,0 +1,396 @@
|
||||
English | [한국어](README.ko.md)
|
||||
|
||||
## Contents
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [TL;DR](#tldr)
|
||||
- [Installation](#installation)
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Features](#features)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [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)
|
||||
- [Warnings](#warnings)
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- **Model Setup Required**
|
||||
- This plugin utilizes models from [OpenCode Zen](https://opencode.ai/docs/zen/), Google, OpenAI, and Anthropic.
|
||||
- For Anthropic models, use [OpenCode's built-in Claude Code Max Plan login](https://opencode.ai/docs/providers/#anthropic).
|
||||
- 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.**
|
||||
- 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
|
||||
|
||||
Add to `~/.config/opencode/opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## For LLM Agents
|
||||
|
||||
<details>
|
||||
<summary>For LLM Agents (Click to expand)</summary>
|
||||
|
||||
If you are an LLM Agent helping a user set up oh-my-opencode, follow these steps:
|
||||
|
||||
### Step 1: Verify OpenCode Installation
|
||||
|
||||
```bash
|
||||
if command -v opencode &> /dev/null; then
|
||||
echo "OpenCode $(opencode --version) is installed"
|
||||
else
|
||||
echo "OpenCode is not installed. Please install it first."
|
||||
echo "See: https://opencode.ai/docs"
|
||||
fi
|
||||
```
|
||||
|
||||
If OpenCode is not installed, guide the user to [OpenCode Installation Guide](https://opencode.ai/docs).
|
||||
|
||||
### Step 2: Configure oh-my-opencode Plugin
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/opencode
|
||||
|
||||
if [ -f ~/.config/opencode/opencode.json ]; then
|
||||
# Existing config: add plugin using jq or python
|
||||
if command -v jq &> /dev/null; then
|
||||
jq '.plugin = ((.plugin // []) + ["oh-my-opencode"] | unique)' \
|
||||
~/.config/opencode/opencode.json > /tmp/oc.json && \
|
||||
mv /tmp/oc.json ~/.config/opencode/opencode.json
|
||||
else
|
||||
echo "Add \"oh-my-opencode\" to plugin array in ~/.config/opencode/opencode.json"
|
||||
fi
|
||||
else
|
||||
# New config
|
||||
echo '{"plugin":["oh-my-opencode"]}' > ~/.config/opencode/opencode.json
|
||||
fi
|
||||
```
|
||||
|
||||
### Step 3: Verify Setup
|
||||
|
||||
```bash
|
||||
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.
|
||||
|
||||
</details>
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
OpenCode is limitlessly extensible and customizable. Zero screen flicker.
|
||||
[LSP](https://opencode.ai/docs/lsp/), [linters, formatters](https://opencode.ai/docs/formatters/)? Automatic and fully configurable.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Consider this a superior AmpCode, a superior Claude Code, or simply a specialized distribution.
|
||||
|
||||
I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI or VibeProxy. Employ the best LLMs from frontier labs. You are in command.
|
||||
|
||||
**Note**: This setup is highly opinionated. It represents the generic component of my personal configuration, so it evolves constantly. I have spent tokens worth $20,000 just for my personal programming usages, and this plugin represents the apex of that experience. You simply inherit the best. If you have superior ideas, PRs are welcome.
|
||||
|
||||
## Features
|
||||
|
||||
### Hooks
|
||||
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── 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.
|
||||
- **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.
|
||||
- **librarian** (`anthropic/claude-haiku-4-5`): Multi-repo analysis, documentation lookup, and implementation examples. Haiku is chosen for its speed, competence, excellent tool usage, and cost-efficiency. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`): Fast exploration and pattern matching. Claude Code uses Haiku; we use Grok. It is currently free, blazing fast, and intelligent enough for file traversal. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): A technical writing expert. Gemini is a wordsmith; it writes prose that flows naturally.
|
||||
|
||||
Each agent is automatically invoked by the main agent, but you can also explicitly request them:
|
||||
|
||||
```
|
||||
@oracle Please think through the design of this part and suggest an architecture.
|
||||
@librarian Tell me how this is implemented — why does the behavior keep changing internally?
|
||||
@explore Tell me about the policy for this feature.
|
||||
```
|
||||
|
||||
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
|
||||
|
||||
### Tools
|
||||
|
||||
#### Built-in LSP Tools
|
||||
|
||||
The features you use in your editor—other agents cannot access them. Oh My OpenCode hands those very tools to your LLM Agent. Refactoring, navigation, and analysis are all supported using the same OpenCode configuration.
|
||||
|
||||
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis. Oh My OpenCode equips you with navigation and refactoring tools matching the same specification.
|
||||
|
||||
- **lsp_hover**: Get type info, docs, signatures at position
|
||||
- **lsp_goto_definition**: Jump to symbol definition
|
||||
- **lsp_find_references**: Find all usages across workspace
|
||||
- **lsp_document_symbols**: Get file's symbol outline
|
||||
- **lsp_workspace_symbols**: Search symbols by name across project
|
||||
- **lsp_diagnostics**: Get errors/warnings before build
|
||||
- **lsp_servers**: List available LSP servers
|
||||
- **lsp_prepare_rename**: Validate rename operation
|
||||
- **lsp_rename**: Rename symbol across workspace
|
||||
- **lsp_code_actions**: Get available quick fixes/refactorings
|
||||
- **lsp_code_action_resolve**: Apply a code action
|
||||
|
||||
#### Built-in AST-Grep Tools
|
||||
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
|
||||
#### Grep
|
||||
|
||||
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
|
||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||
- This tool enforces strict limits and completely replaces the built-in `grep`.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
|
||||
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
|
||||
- This tool enforces timeouts and kills the process on expiration.
|
||||
|
||||
#### Built-in MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI web search. Performs real-time web searches and can scrape content from specific URLs. Returns LLM-optimized context from relevant websites.
|
||||
- **context7**: Library documentation lookup. Fetches up-to-date documentation for any library to assist with accurate coding.
|
||||
|
||||
Don't need these? Disable them via `oh-my-opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
Configuration file locations (in priority order):
|
||||
1. `.opencode/oh-my-opencode.json` (project)
|
||||
2. `~/.config/opencode/oh-my-opencode.json` (user)
|
||||
|
||||
Schema autocomplete is supported:
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
### Agents
|
||||
|
||||
Override built-in agent settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5",
|
||||
"temperature": 0.5
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"disable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
|
||||
|
||||
Or disable agents via `disabled_agents`:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_agents": ["oracle", "frontend-ui-ux-engineer"]
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
|
||||
### MCPs
|
||||
|
||||
Disable built-in MCPs:
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Configure LSP servers via `lsp` option:
|
||||
|
||||
```json
|
||||
{
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"command": ["typescript-language-server", "--stdio"],
|
||||
"extensions": [".ts", ".tsx"],
|
||||
"priority": 10
|
||||
},
|
||||
"pylsp": {
|
||||
"disabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
|
||||
|
||||
## Author's Note
|
||||
|
||||
Install Oh My OpenCode. Do not waste time configuring OpenCode from scratch.
|
||||
I have resolved the friction so you don't have to. The answers are in this plugin. If OpenCode is Arch Linux, Oh My OpenCode is [Omarchy](https://omarchy.org/).
|
||||
|
||||
Enjoy the multi-model stability and rich feature set that other harnesses promise but fail to deliver.
|
||||
I will continue testing and updating here. I am the primary user of this project.
|
||||
|
||||
- Who possesses the best raw logic?
|
||||
- Who is the debugging god?
|
||||
- Who writes the best prose?
|
||||
- Who dominates frontend?
|
||||
- Who owns backend?
|
||||
- Which model is fastest for daily driving?
|
||||
- What new features are other harnesses shipping?
|
||||
|
||||
Do not overthink it. I have done the thinking. I will integrate the best practices. I will update this.
|
||||
If this sounds arrogant and you have a superior solution, send a PR. You are welcome.
|
||||
|
||||
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.
|
||||
## 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.
|
||||
- [The fix](https://github.com/sst/opencode/pull/5040) was merged after 1.0.132, so use a newer version.
|
||||
159
assets/oh-my-opencode.schema.json
Normal file
159
assets/oh-my-opencode.schema.json
Normal file
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"$id": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
"title": "Oh My OpenCode Configuration",
|
||||
"description": "Configuration schema for oh-my-opencode plugin",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"$schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"disabled_mcps": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"websearch_exa",
|
||||
"context7"
|
||||
]
|
||||
}
|
||||
},
|
||||
"disabled_agents": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer"
|
||||
]
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"oracle",
|
||||
"librarian",
|
||||
"explore",
|
||||
"frontend-ui-ux-engineer",
|
||||
"document-writer"
|
||||
]
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model": {
|
||||
"type": "string"
|
||||
},
|
||||
"temperature": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 2
|
||||
},
|
||||
"top_p": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string"
|
||||
},
|
||||
"tools": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"disable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"subagent",
|
||||
"primary",
|
||||
"all"
|
||||
]
|
||||
},
|
||||
"color": {
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9A-Fa-f]{6}$"
|
||||
},
|
||||
"permission": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"edit": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"bash": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"webfetch": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"doom_loop": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
},
|
||||
"external_directory": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ask",
|
||||
"allow",
|
||||
"deny"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
bun.lock
15
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.4",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"tree-sitter-wasms": "^0.1.12",
|
||||
"web-tree-sitter": "^0.24.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": {
|
||||
@@ -23,6 +24,8 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi",
|
||||
"@code-yeongyu/comment-checker",
|
||||
],
|
||||
"packages": {
|
||||
"@ast-grep/cli": ["@ast-grep/cli@0.40.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.0", "@ast-grep/cli-darwin-x64": "0.40.0", "@ast-grep/cli-linux-arm64-gnu": "0.40.0", "@ast-grep/cli-linux-x64-gnu": "0.40.0", "@ast-grep/cli-win32-arm64-msvc": "0.40.0", "@ast-grep/cli-win32-ia32-msvc": "0.40.0", "@ast-grep/cli-win32-x64-msvc": "0.40.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-L8AkflsfI2ZP70yIdrwqvjR02ScCuRmM/qNGnJWUkOFck+e6gafNVJ4e4jjGQlEul+dNdBpx36+O2Op629t47A=="],
|
||||
@@ -61,6 +64,8 @@
|
||||
|
||||
"@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.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=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.128", "", {}, "sha512-Kow3Ivg8bR8dNRp8C0LwF9e8+woIrwFgw3ZALycwCfqS/UujDkJiBeYHdr1l/07GSHP9sZPmvJ6POuvfZ923EA=="],
|
||||
@@ -95,14 +100,16 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"tree-sitter-wasms": ["tree-sitter-wasms@0.1.13", "", { "dependencies": { "tree-sitter-wasms": "^0.1.11" } }, "sha512-wT+cR6DwaIz80/vho3AvSF0N4txuNx/5bcRKoXouOfClpxh/qqrF4URNLQXbbt8MaAxeksZcZd1j8gcGjc+QxQ=="],
|
||||
"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=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.24.7", "", {}, "sha512-CdC/TqVFbXqR+C51v38hv6wOPatKEUGxa39scAeFSm98wIhZxAYonhRQPSMmfZ2w7JDI0zQDdzdmgtNk06/krQ=="],
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
|
||||
784
notepad.md
784
notepad.md
@@ -59,3 +59,787 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||
|
||||
---
|
||||
|
||||
## [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분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:13] - Task 1: Add file-based logger to shared module
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy and modification task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied logger.ts from opencode-cc-plugin source
|
||||
- Changed log file path from `opencode-cc-plugin.log` to `oh-my-opencode.log`
|
||||
- Added barrel export from `src/shared/index.ts`
|
||||
- Kept original comment for module description
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified - logger is now available for use in all new loaders
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Log file path verified: `/tmp/oh-my-opencode.log`
|
||||
- Exports verified: `log()` and `getLogFilePath()` now accessible via `src/shared`
|
||||
|
||||
### LEARNINGS
|
||||
- Source file location: `~/local-workspaces/opencode-cc-plugin/src/shared/logger.ts`
|
||||
- Logger uses `fs.appendFileSync` for synchronous file writing
|
||||
- Empty catch block intentionally swallows errors to prevent logging from breaking main operations
|
||||
|
||||
소요 시간: ~1분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:14] - Task 3: Rename skill-loader to claude-code-skill-loader
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward directory rename task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Used `mv` command to rename `src/features/skill-loader/` → `src/features/claude-code-skill-loader/`
|
||||
- Updated import path in `src/index.ts` (lines 19-22)
|
||||
- Did NOT modify internal imports (`../command-loader/types`) since command-loader still exists (Task 2 runs in parallel)
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- If Task 2 renames command-loader to claude-code-command-loader, the internal imports in claude-code-skill-loader will need to be updated as part of Task 2's scope
|
||||
- The skill-loader's loader.ts:6 and types.ts:1 import from `../command-loader/types`
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, succeeded
|
||||
- Directory structure verified: `skill-loader/` deleted, `claude-code-skill-loader/` exists
|
||||
|
||||
### LEARNINGS
|
||||
- This project uses `mv` for directory rename (acceptable per ANTI-PATTERNS - file creation is forbidden, not rename)
|
||||
- Command: `bun run typecheck` for type check, `bun run build` for build
|
||||
- skill-loader internal imports use relative paths (`../command-loader/types`) which remain valid after rename
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:16] - Task 2: Rename command-loader to claude-code-command-loader
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- skill-loader (now claude-code-skill-loader) was importing `CommandDefinition` from `../command-loader/types`
|
||||
- After renaming command-loader, these references also needed updating
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Used `mv` command: `src/features/command-loader/` → `src/features/claude-code-command-loader/`
|
||||
- Updated import path in `src/index.ts` (lines 13-18)
|
||||
- Also updated `claude-code-skill-loader/loader.ts:6` and `types.ts:1` to reference new path
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified - all dependent imports updated
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Directory structure verified: `command-loader/` deleted, `claude-code-command-loader/` exists
|
||||
- All imports updated: src/index.ts, claude-code-skill-loader/loader.ts, claude-code-skill-loader/types.ts
|
||||
|
||||
### LEARNINGS
|
||||
- skill-loader depends on command-loader's `CommandDefinition` type via relative import
|
||||
- When renaming shared modules, must update ALL dependent modules' imports
|
||||
- Task 2 and Task 3 have an implicit dependency through the type import
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:24] - Task 4: Add claude-code-agent-loader feature
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied 3 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `types.ts`
|
||||
- Import path `../../shared/frontmatter` unchanged - already compatible with oh-my-opencode structure
|
||||
- No `log()` usage in source files - no logger integration needed
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified - agent-loader is self-contained
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Directory structure verified: `claude-code-agent-loader/` created with 3 files
|
||||
- Functions exported: `loadUserAgents()`, `loadProjectAgents()`
|
||||
|
||||
### LEARNINGS
|
||||
- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/agent-loader/`
|
||||
- Agent loader uses `parseFrontmatter` from shared module
|
||||
- Agent configs loaded from `~/.claude/agents/` (user) and `.claude/agents/` (project)
|
||||
- Scope is appended to description: `(user)` or `(project)`
|
||||
|
||||
소요 시간: ~1분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:25] - Task 5: Add claude-code-mcp-loader feature
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied 5 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `transformer.ts`, `env-expander.ts`, `types.ts`
|
||||
- Import path `../../shared/logger` unchanged - already compatible with oh-my-opencode structure
|
||||
- Kept `Bun.file()` usage - oh-my-opencode targets Bun runtime
|
||||
- Environment variable expansion supports `${VAR}` and `${VAR:-default}` syntax
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None identified - mcp-loader is self-contained
|
||||
- Does NOT conflict with src/mcp/ (builtin MCPs are separate)
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Directory structure verified: `claude-code-mcp-loader/` created with 5 files
|
||||
- Functions exported: `loadMcpConfigs()`, `formatLoadedServersForToast()`, `transformMcpServer()`, `expandEnvVars()`, `expandEnvVarsInObject()`
|
||||
|
||||
### LEARNINGS
|
||||
- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/mcp-loader/`
|
||||
- MCP configs loaded from:
|
||||
- `~/.claude/.mcp.json` (user scope)
|
||||
- `.mcp.json` (project scope)
|
||||
- `.claude/.mcp.json` (local scope)
|
||||
- Later scope overrides earlier scope for same server name
|
||||
- Supports stdio, http, and sse server types
|
||||
|
||||
소요 시간: ~1분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:24] - Task 6: Add claude-code-session-state feature
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied 4 files from opencode-cc-plugin: `types.ts`, `state.ts`, `detector.ts`, `index.ts`
|
||||
- No import path changes needed - files are completely self-contained
|
||||
- No external dependencies - types are defined locally
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 7 should import from `./features/claude-code-session-state` in src/index.ts
|
||||
- Task 7 should remove local session variables and use the module's getter/setters
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Directory created: `src/features/claude-code-session-state/` (4 files confirmed)
|
||||
- Exports available: sessionErrorState, sessionInterruptState, subagentSessions, sessionFirstMessageProcessed (Maps/Sets)
|
||||
- Exports available: currentSessionID, currentSessionTitle, mainSessionID (state vars)
|
||||
- Exports available: setCurrentSession(), setMainSession(), getCurrentSessionID(), getCurrentSessionTitle(), getMainSessionID() (getters/setters)
|
||||
- Exports available: detectInterrupt() function
|
||||
|
||||
### LEARNINGS
|
||||
- Session state module is completely self-contained - no external dependencies
|
||||
- Uses barrel export pattern: index.ts re-exports everything from types, state, detector
|
||||
- Source directory: `~/local-workspaces/opencode-cc-plugin/src/features/session-state/`
|
||||
|
||||
소요 시간: ~1분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:32] - Task 7: Integrate new features into src/index.ts
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - integration task with well-defined API from previous tasks
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Added imports for new modules:
|
||||
- `loadUserAgents`, `loadProjectAgents` from `./features/claude-code-agent-loader`
|
||||
- `loadMcpConfigs` from `./features/claude-code-mcp-loader`
|
||||
- `setCurrentSession`, `setMainSession`, `getMainSessionID`, `getCurrentSessionTitle` from `./features/claude-code-session-state`
|
||||
- `log` from `./shared/logger`
|
||||
- Removed local session variables (lines 77-79): `mainSessionID`, `currentSessionID`, `currentSessionTitle`
|
||||
- Replaced direct session assignments with setter functions:
|
||||
- `mainSessionID = x` → `setMainSession(x)`
|
||||
- `currentSessionID = x; currentSessionTitle = y` → `setCurrentSession(x, y)`
|
||||
- Replaced session variable reads with getter functions:
|
||||
- `mainSessionID` comparisons → `getMainSessionID()`
|
||||
- `currentSessionTitle` reads → `getCurrentSessionTitle()`
|
||||
- Added agent loading in config hook: `loadUserAgents()`, `loadProjectAgents()`
|
||||
- Added MCP loading in config hook: `await loadMcpConfigs()` (async)
|
||||
- Replaced `console.error` with `log()` for config validation errors
|
||||
- Renamed local variable `agents` to `builtinAgents` to distinguish from loaded agents
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 8 (README update) should document the new Agent Loader and MCP Loader features
|
||||
- Should explain the `claude-code-*` naming convention
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, successful build
|
||||
- Session tracking verified: all event handlers use getter/setter functions
|
||||
- Agent loading verified: config.agent merges builtin + user + project agents
|
||||
- MCP loading verified: config.mcp merges builtin MCPs + loaded MCP servers
|
||||
|
||||
### LEARNINGS
|
||||
- `setCurrentSession(id, title)` sets both ID and title atomically
|
||||
- `loadMcpConfigs()` is async - must use `await` in config hook
|
||||
- MCP result has `.servers` property that returns the server configs
|
||||
- Order matters in spread: later values override earlier (projectAgents > userAgents > builtinAgents)
|
||||
|
||||
소요 시간: ~4분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 16:35] - Task 8: Update README.md documentation
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - documentation update task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Added Agent Loader feature documentation under "Other Features" section (line 235-238)
|
||||
- User scope: `~/.claude/agents/`
|
||||
- Project scope: `./.claude/agents/`
|
||||
- Format: `*.md` files with YAML frontmatter
|
||||
- Added MCP Loader feature documentation (line 239-243)
|
||||
- User scope: `~/.claude/.mcp.json`
|
||||
- Project scope: `./.mcp.json`
|
||||
- Local scope: `./.claude/.mcp.json`
|
||||
- Environment variable expansion (`${VAR}` syntax)
|
||||
- Added `claude-code-*` naming convention explanation as a blockquote note (line 245)
|
||||
- Explains features migrated from Claude Code
|
||||
- Lists examples: claude-code-command-loader, skill-loader, agent-loader, mcp-loader
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None - this is the final task
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- README.md updated with new documentation
|
||||
- Style matches existing documentation (bullet points, code blocks for paths)
|
||||
- No sections removed or modified (only additions)
|
||||
|
||||
### LEARNINGS
|
||||
- README.md "Other Features" section is at line 224
|
||||
- Existing features: Terminal Title, Command Loader, Skill Loader
|
||||
- Documentation style: bold feature name, bullet points for scopes/details
|
||||
|
||||
소요 시간: ~1분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:24] - Task 0: Shared Utilities 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- command-executor.ts already existed but had minor whitespace differences (indentation inconsistency)
|
||||
- pattern-matcher.ts and hook-disabled.ts import from `../claude-compat/types` which doesn't exist yet in oh-my-opencode
|
||||
- Types will be created in Task 1 at `src/hooks/claude-code-hooks/types.ts`
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Created snake-case.ts and tool-name.ts (no dependencies) - exact copy from source
|
||||
- Created temporary stub types at `src/hooks/claude-code-hooks/types.ts` with minimal definitions needed for shared utilities
|
||||
- Created pattern-matcher.ts with adjusted import: `../claude-compat/types` → `../hooks/claude-code-hooks/types`
|
||||
- Created hook-disabled.ts with adjusted import to point to stub types
|
||||
- Added all new utilities to `src/shared/index.ts` using barrel export pattern
|
||||
- Stub types include: HookCommand, HookMatcher, ClaudeHooksConfig, ClaudeHookEvent, PluginConfig
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 1 will replace stub types with full implementation from opencode-cc-plugin
|
||||
- Stub types in `src/hooks/claude-code-hooks/types.ts` are marked with comments indicating they're temporary
|
||||
- The real PluginConfig will likely be different - current stub only supports `disabledHooks` field
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- All 5 functions exported: executeHookCommand, objectToSnakeCase, transformToolName, findMatchingHooks, isHookDisabled
|
||||
- Import paths verified: pattern-matcher.ts and hook-disabled.ts successfully import from stub types
|
||||
|
||||
### LEARNINGS
|
||||
- Import paths must be adjusted when porting between different project structures
|
||||
- opencode-cc-plugin structure: `src/claude-compat/` → oh-my-opencode structure: `src/hooks/claude-code-hooks/`
|
||||
- Stub types strategy allows Task 0 to complete and typecheck to pass before Task 1 implements full types
|
||||
- command-executor.ts in oh-my-opencode had indentation inconsistency (not 100% identical to source)
|
||||
|
||||
소요 시간: ~5분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:34] - Task 1: types.ts 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- Stub types.ts had `PluginConfig` interface needed by hook-disabled.ts (from Task 0)
|
||||
- Full types.ts from opencode-cc-plugin did NOT have `PluginConfig`
|
||||
- Typecheck initially failed: Module has no exported member 'PluginConfig'
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied full types.ts (181 lines) from opencode-cc-plugin → oh-my-opencode
|
||||
- Preserved ALL types: ClaudeHooksConfig, HookMatcher, PreToolUseInput/Output, PostToolUseInput/Output
|
||||
- Preserved deprecated decision fields: `decision?: "allow" | "deny" | "approve" | "block" | "ask"`
|
||||
- Added `PluginConfig` interface at end (oh-my-opencode specific type needed by hook-disabled.ts)
|
||||
- Kept line 150 comment (`// "pending" | "in_progress" | "completed"`) - existing source comment
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- PluginConfig is now available for all subsequent tasks
|
||||
- Full type definitions ready for Task 2, 3, 4+ to use
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Verified: ClaudeHooksConfig, HookMatcher, HookCommand types exist
|
||||
- Verified: PreToolUseInput/Output, PostToolUseInput/Output types exist
|
||||
- Verified: deprecated decision field (approve/block) included in PreToolUseOutput
|
||||
- Verified: PluginConfig export added (fixes hook-disabled.ts import)
|
||||
|
||||
### LEARNINGS
|
||||
- opencode-cc-plugin types.ts: 181 lines, no PluginConfig
|
||||
- oh-my-opencode requires PluginConfig for hook disabling functionality
|
||||
- Stub-to-full replacement pattern works: stub allows Task 0 typecheck, Task 1 replaces with full implementation
|
||||
- Must preserve project-specific types (PluginConfig) when porting from different codebases
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:39] - Task 3: tool-input-cache.ts 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied tool-input-cache.ts (48 lines) from opencode-cc-plugin → oh-my-opencode
|
||||
- Preserved cache structure:
|
||||
* Key format: `${sessionId}:${toolName}:${invocationId}`
|
||||
* TTL: 60000ms (1 minute) as CACHE_TTL constant
|
||||
* Periodic cleanup: setInterval every CACHE_TTL (60000ms)
|
||||
- Preserved original comments from source file (lines 12, 39)
|
||||
- Functions: cacheToolInput(), getToolInput()
|
||||
- Cache behavior: getToolInput() deletes entry immediately after retrieval (single-use cache)
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 4 (pre-tool-use.ts) will call cacheToolInput() to store tool inputs
|
||||
- Task 5 (post-tool-use.ts) will call getToolInput() to retrieve cached inputs for transcript building
|
||||
- No import path changes needed - this file has no external dependencies
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- File created: `src/hooks/claude-code-hooks/tool-input-cache.ts` (48 lines)
|
||||
- Functions exported: cacheToolInput(), getToolInput()
|
||||
- TTL verified: CACHE_TTL = 60000 (1 minute)
|
||||
- Cleanup interval verified: setInterval(cleanup, CACHE_TTL)
|
||||
|
||||
### LEARNINGS
|
||||
- Tool input cache is a temporary storage for PreToolUse → PostToolUse communication
|
||||
- Single-use pattern: getToolInput() deletes entry after first retrieval (line 33)
|
||||
- TTL check happens after deletion, so expired entries still return null
|
||||
- setInterval runs in background for periodic cleanup of abandoned entries
|
||||
- Source location: `~/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/tool-input-cache.ts`
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:39] - Task 2: config.ts + transcript.ts + todo.ts 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- transcript.ts had unused imports (ClaudeCodeMessage, ClaudeCodeContent) - same as source file
|
||||
- LSP warned about unused types - removed from import to clean up
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied config.ts (101 lines) - no import path changes needed (only uses `./types` and Node.js builtins)
|
||||
- Copied transcript.ts (256 lines) - changed import path:
|
||||
* Line 10: `../shared/tool-name` → `../../shared/tool-name` (opencode-cc-plugin depth 1, oh-my-opencode depth 2)
|
||||
- Copied todo.ts (78 lines) - no import path changes needed (only uses `./types` and Node.js builtins)
|
||||
- Removed unused imports from transcript.ts: ClaudeCodeMessage, ClaudeCodeContent (not used in function bodies)
|
||||
- Preserved ALL original comments from source files - these are pre-existing comments
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 3 will import cacheToolInput/getToolInput for cache functionality
|
||||
- Task 4 will import loadClaudeHooksConfig, buildTranscriptFromSession
|
||||
- Task 5 will import transcript building functions for PostToolUse hook
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Files created: config.ts (101 lines), transcript.ts (256 lines), todo.ts (78 lines)
|
||||
- Functions available: loadClaudeHooksConfig(), buildTranscriptFromSession(), appendTranscriptEntry(), loadTodoFile(), saveTodoFile()
|
||||
- Import paths verified: transcript.ts successfully imports transformToolName from ../../shared
|
||||
|
||||
### LEARNINGS
|
||||
- Import path depth difference: opencode-cc-plugin `src/claude-compat/` (1 level up) → oh-my-opencode `src/hooks/claude-code-hooks/` (2 levels up)
|
||||
- transcript.ts unused imports were present in original source - cleaning them is optional but improves code hygiene
|
||||
- config.ts uses Bun.file() for async file reading - compatible with oh-my-opencode's Bun runtime
|
||||
- Bun.file().text() automatically handles encoding
|
||||
|
||||
소요 시간: ~3분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:48] - Task 4: pre-tool-use.ts 포팅 (+ plugin-config.ts, config-loader.ts)
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- pre-tool-use.ts depends on DEFAULT_CONFIG and isHookCommandDisabled which weren't created yet
|
||||
- Plan document listed plugin-config.ts and config-loader.ts as separate task (Section 4), but not mentioned in Task 4 instructions
|
||||
- These dependency files needed to be created before pre-tool-use.ts could compile
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Created plugin-config.ts (9 lines) with DEFAULT_CONFIG containing forceZsh and zshPath settings
|
||||
* Minimal version - only fields used by pre-tool-use.ts (not full opencode-cc-plugin config)
|
||||
* forceZsh: true, zshPath: "/bin/zsh"
|
||||
- Created config-loader.ts (105 lines) - full copy from opencode-cc-plugin
|
||||
* Changed import: `../claude-compat/types` → `./types`
|
||||
* Changed import: `../shared/logger` → `../../shared/logger`
|
||||
* Functions: loadPluginExtendedConfig(), isHookCommandDisabled()
|
||||
* Supports regex patterns for disabling specific hook commands
|
||||
- Created pre-tool-use.ts (172 lines) - full copy with adjusted imports:
|
||||
* `../types` → `./types`
|
||||
* `../../shared` → `../../shared` (unchanged)
|
||||
* `../../config` → `./plugin-config` (NEW file)
|
||||
* `../../config-loader` → `./config-loader` (NEW file)
|
||||
- Preserved ALL exit code logic:
|
||||
* exitCode === 2 → decision = "deny"
|
||||
* exitCode === 1 → decision = "ask"
|
||||
* exitCode === 0 → parse JSON for decision
|
||||
- Preserved ALL deprecated field support:
|
||||
* decision: "approve" → "allow"
|
||||
* decision: "block" → "deny"
|
||||
- Original comments from source preserved (backward compat, spec references)
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 5 (post-tool-use.ts) can now import executePreToolUseHooks if needed
|
||||
- plugin-config.ts and config-loader.ts are now available for all subsequent hook implementations
|
||||
- isHookCommandDisabled pattern can be reused in PostToolUse, UserPromptSubmit, Stop hooks
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, successful
|
||||
- Committed: 530c4d6 "feat(hooks): add PreToolUse hook executor"
|
||||
* 4 files: tool-input-cache.ts (Task 3), plugin-config.ts, config-loader.ts, pre-tool-use.ts (Task 4)
|
||||
* 333 insertions total
|
||||
- Functions available: executePreToolUseHooks(), isHookCommandDisabled(), loadPluginExtendedConfig()
|
||||
- Exit code mapping verified: lines 96-116 check exitCode === 2/1/0
|
||||
- Deprecated field mapping verified: lines 132-141 check decision === "approve"/"block"
|
||||
|
||||
### LEARNINGS
|
||||
- Pre-tool-use.ts depends on plugin configuration that wasn't part of oh-my-opencode's original structure
|
||||
- plugin-config.ts only needs subset of opencode-cc-plugin's config.ts (forceZsh, zshPath for executeHookCommand)
|
||||
- config-loader.ts provides hook command filtering via regex patterns (disabledHooks config)
|
||||
- executeHookCommand from shared/ accepts ExecuteHookOptions{ forceZsh, zshPath } parameter
|
||||
- Task 3 + Task 4 grouped in single commit per plan requirement
|
||||
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/pre-tool-use.ts` (173 lines)
|
||||
|
||||
소요 시간: ~5분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:52] - Task 5: post-tool-use.ts 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy with import path adjustments
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied post-tool-use.ts (200 lines) from opencode-cc-plugin → oh-my-opencode
|
||||
- Import path adjustments:
|
||||
* `../types` → `./types`
|
||||
* `../../shared` → `../../shared` (unchanged)
|
||||
* `../../config` → `./plugin-config`
|
||||
* `../transcript` → `./transcript`
|
||||
* `../../config-loader` → `./config-loader`
|
||||
- Preserved ALL transcript logic:
|
||||
* buildTranscriptFromSession() call with client.session.messages() API
|
||||
* Temp file creation in try block
|
||||
* deleteTempTranscript() cleanup in finally block
|
||||
- Preserved ALL exit code handling:
|
||||
* exitCode === 2 → warning (continue)
|
||||
* exitCode === 0 → parse JSON for decision: "block"
|
||||
* Non-zero, non-2 → parse JSON for decision: "block"
|
||||
- Preserved ALL output fields: block, reason, message, warnings, elapsedMs, additionalContext, continue, stopReason, suppressOutput, systemMessage
|
||||
- Original comments from source preserved (PORT FROM DISABLED, cleanup explanation)
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 6 (user-prompt-submit.ts, stop.ts) can use similar pattern for hook execution
|
||||
- plugin-config.ts, config-loader.ts, transcript.ts dependencies already in place
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, successful
|
||||
- File created: `src/hooks/claude-code-hooks/post-tool-use.ts` (200 lines)
|
||||
- Functions available: executePostToolUseHooks()
|
||||
- Transcript integration verified: buildTranscriptFromSession() imported from ./transcript
|
||||
- Cleanup mechanism verified: deleteTempTranscript() in finally block (line 196)
|
||||
|
||||
### LEARNINGS
|
||||
- PostToolUse differs from PreToolUse: no permission decision (allow/deny/ask), only block/continue
|
||||
- PostToolUse provides hook results via message/warnings/additionalContext (observability, not control)
|
||||
- Exit code 2 in PostToolUse = warning (not block), collected in warnings array
|
||||
- Transcript temp file pattern: create in try, cleanup in finally (prevents disk accumulation)
|
||||
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/post-tool-use.ts` (200 lines)
|
||||
|
||||
소요 시간: ~5분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:58] - Task 6: user-prompt-submit.ts + stop.ts 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy with import path adjustments
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Copied user-prompt-submit.ts (118 lines) from opencode-cc-plugin → oh-my-opencode
|
||||
- Copied stop.ts (119 lines) from opencode-cc-plugin → oh-my-opencode
|
||||
- Import path adjustments (both files):
|
||||
* `../types` → `./types`
|
||||
* `../../shared` → `../../shared` (unchanged)
|
||||
* `../../config` → `./plugin-config`
|
||||
* `../../config-loader` → `./config-loader`
|
||||
* `../todo` → `./todo` (stop.ts only)
|
||||
- Preserved recursion prevention logic in user-prompt-submit.ts:
|
||||
* Tags: `<user-prompt-submit-hook>` (open/close)
|
||||
* Check if prompt already contains tags → return early
|
||||
* Wrap hook stdout with tags to prevent infinite recursion
|
||||
- Preserved inject_prompt support:
|
||||
* user-prompt-submit: messages array collection for injection
|
||||
* stop: injectPrompt field in result (from output.inject_prompt or output.reason)
|
||||
- Preserved stopHookActiveState management in stop.ts:
|
||||
* Module-level Map<string, boolean> for per-session state
|
||||
* setStopHookActive(), getStopHookActive() exported
|
||||
* State persists across hook invocations
|
||||
- Preserved exit code handling:
|
||||
* stop.ts: exitCode === 2 → block with reason
|
||||
* user-prompt-submit.ts: exitCode !== 0 → check JSON for decision: "block"
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 7 (hook-message-injector) will use the message injection pattern
|
||||
- Task 8 (Factory + Integration) will wire these hooks to OpenCode lifecycle events
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, successful
|
||||
- Files created:
|
||||
* `src/hooks/claude-code-hooks/user-prompt-submit.ts` (115 lines)
|
||||
* `src/hooks/claude-code-hooks/stop.ts` (119 lines)
|
||||
- Functions available:
|
||||
* executeUserPromptSubmitHooks() with UserPromptSubmitContext → UserPromptSubmitResult
|
||||
* executeStopHooks() with StopContext → StopResult
|
||||
* setStopHookActive(), getStopHookActive()
|
||||
- Recursion prevention verified: lines 47-52 check for tag presence
|
||||
- inject_prompt field verified: stop.ts line 102 sets injectPrompt from output
|
||||
|
||||
### LEARNINGS
|
||||
- user-prompt-submit uses tag wrapping pattern to prevent infinite hook loops
|
||||
- stop hook can inject prompts into session via injectPrompt result field
|
||||
- stopHookActiveState Map persists across hook invocations (module-level state)
|
||||
- getTodoPath() from ./todo provides todo file path for Stop hook context
|
||||
- Source files:
|
||||
* `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/user-prompt-submit.ts` (118 lines)
|
||||
* `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/stop.ts` (119 lines)
|
||||
|
||||
소요 시간: ~3분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 17:58] - Task 7: hook-message-injector 포팅
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - straightforward file copy task
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Created `src/features/hook-message-injector/` directory
|
||||
- Copied 4 files from opencode-cc-plugin → oh-my-opencode:
|
||||
* constants.ts (9 lines): XDG-based path definitions (MESSAGE_STORAGE, PART_STORAGE)
|
||||
* types.ts (46 lines): MessageMeta, OriginalMessageContext, TextPart interfaces
|
||||
* injector.ts (142 lines): injectHookMessage() implementation with message/part storage
|
||||
* index.ts (3 lines): Barrel export
|
||||
- No import path changes needed - module is self-contained
|
||||
- Preserved XDG_DATA_HOME environment variable support
|
||||
- Preserved message fallback logic: finds nearest message with agent/model/tools if not provided
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- Task 8 (Factory + Integration) will import injectHookMessage from this module
|
||||
- Hook executors (user-prompt-submit, stop) can use injectHookMessage to store hook messages
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Files created: src/features/hook-message-injector/ (4 files)
|
||||
- Functions exported: injectHookMessage()
|
||||
- Types exported: MessageMeta, OriginalMessageContext, TextPart
|
||||
- Constants exported: MESSAGE_STORAGE, PART_STORAGE (XDG-based paths)
|
||||
|
||||
### LEARNINGS
|
||||
- Message injector uses XDG_DATA_HOME for storage (~/.local/share/opencode/storage/)
|
||||
- Message storage structure: sessionID → messageID.json (meta) + partID.json (content)
|
||||
- Fallback logic: searches recent messages for agent/model/tools if originalMessage is incomplete
|
||||
- Part-based storage allows incremental message building
|
||||
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/features/hook-message-injector/`
|
||||
|
||||
소요 시간: ~2분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 18:08] - Task 8: Factory 생성 + 통합
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- None - final integration task with well-defined hook executors from previous tasks
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- Created `src/hooks/claude-code-hooks/index.ts` (146 lines) with createClaudeCodeHooksHook() factory
|
||||
- Factory returns hook handler object with 3 handlers:
|
||||
* `tool.execute.before`: Executes executePreToolUseHooks()
|
||||
- Loads config dynamically (async) on each invocation
|
||||
- Maps OpenCode input → PreToolUseContext
|
||||
- Caches tool input for PostToolUse
|
||||
- Handles deny/ask decisions (deny throws error, ask logs warning)
|
||||
* `tool.execute.after`: Executes executePostToolUseHooks()
|
||||
- Retrieves cached tool input via getToolInput()
|
||||
- Maps OpenCode input → PostToolUseContext with client wrapper
|
||||
- Appends hook message to output if provided
|
||||
- Throws error if block decision returned
|
||||
* `event`: Executes executeStopHooks() for session.idle
|
||||
- Filters event.type === "session.idle"
|
||||
- Maps OpenCode event → StopContext
|
||||
- Injects prompt via ctx.client.session.prompt() if injectPrompt returned
|
||||
- Updated `src/hooks/index.ts`: Added createClaudeCodeHooksHook export
|
||||
- Updated `src/index.ts`:
|
||||
* Imported createClaudeCodeHooksHook
|
||||
* Created claudeCodeHooks instance
|
||||
* Registered handlers in tool.execute.before, tool.execute.after, event hooks
|
||||
* Claude hooks run FIRST in execution order (before other hooks)
|
||||
- Config loading: Async loadClaudeHooksConfig() and loadPluginExtendedConfig() called in each handler (not cached)
|
||||
- Transcript path: Uses getTranscriptPath() function (not buildTranscriptPath which doesn't exist)
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None - this is the final task (Task 8)
|
||||
- All Claude Code Hooks now integrated into oh-my-opencode plugin system
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bun run typecheck` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, successful build
|
||||
- Files modified:
|
||||
* src/hooks/claude-code-hooks/index.ts (created)
|
||||
* src/hooks/index.ts (export added)
|
||||
* src/index.ts (hook registration)
|
||||
- Hook handler registration verified: claudeCodeHooks handlers called in all 3 hook points
|
||||
- Execution order verified: Claude hooks run before existing hooks in tool.execute.*
|
||||
|
||||
### LEARNINGS
|
||||
- OpenCode Plugin API: Factory pattern createXxxHook(ctx: PluginInput) → handlers object
|
||||
- OpenCode does NOT have chat.params hook → UserPromptSubmit not implemented in factory
|
||||
- Config loading must be async → call loadClaudeHooksConfig() in each handler, not once during initialization
|
||||
- Tool input cache is module-level state → cacheToolInput/getToolInput work across handlers
|
||||
- Stop hook only triggers on session.idle event → filter event.type
|
||||
- Import path: getTranscriptPath (exists), not buildTranscriptPath (doesn't exist)
|
||||
|
||||
소요 시간: ~6분
|
||||
|
||||
---
|
||||
|
||||
## [2025-12-09 18:16] - hook-message-injector 사용 패턴 크로스체크
|
||||
|
||||
### DISCOVERED ISSUES
|
||||
- **CRITICAL**: UserPromptSubmit hooks가 oh-my-opencode에 완전히 누락됨
|
||||
- opencode-cc-plugin에서는 chat.message hook으로 UserPromptSubmit 처리
|
||||
- oh-my-opencode에는 chat.message hook이 구현되지 않음
|
||||
- user-prompt-submit.ts는 정의만 있고 실제 사용처 없음
|
||||
|
||||
### IMPLEMENTATION DECISIONS
|
||||
- PostToolUse는 이미 올바르게 구현됨:
|
||||
* opencode-cc-plugin: result.message를 tool output에 append
|
||||
* oh-my-opencode: 동일한 방식 사용 (claude-code-hooks/index.ts:95-97)
|
||||
* injectHookMessage() 불필요
|
||||
- UserPromptSubmit 구현 추가:
|
||||
* chat.message hook handler 추가 (claude-code-hooks/index.ts)
|
||||
* executeUserPromptSubmitHooks() 호출
|
||||
* sessionFirstMessageProcessed Set으로 첫 메시지 skip (title generation)
|
||||
* result.messages가 있으면 injectHookMessage() 호출
|
||||
* src/index.ts에 hook 등록
|
||||
- Import 추가:
|
||||
* executeUserPromptSubmitHooks, UserPromptSubmitContext, MessagePart from ./user-prompt-submit
|
||||
* injectHookMessage from ../../features/hook-message-injector
|
||||
|
||||
### PROBLEMS FOR NEXT TASKS
|
||||
- None - UserPromptSubmit 통합 완료
|
||||
|
||||
### VERIFICATION RESULTS
|
||||
- Ran: `bunx tsc --noEmit` → exit 0, no errors
|
||||
- Ran: `bun run build` → exit 0, successful build
|
||||
- Files modified:
|
||||
* src/hooks/claude-code-hooks/index.ts (chat.message handler 추가)
|
||||
* src/index.ts (chat.message hook 등록)
|
||||
- Verified OpenCode Plugin API: chat.message hook 공식 지원 확인 (@opencode-ai/plugin/dist/index.d.ts:112-123)
|
||||
|
||||
### LEARNINGS
|
||||
- OpenCode Plugin API에 chat.message hook 존재:
|
||||
* input: sessionID, agent?, model?, messageID?
|
||||
* output: message, parts[]
|
||||
- PostToolUse는 tool output에 직접 append (injectHookMessage 불필요)
|
||||
- UserPromptSubmit는 file system injection 사용 (injectHookMessage 필수)
|
||||
- opencode-cc-plugin 구조: src/plugin/chat-handler.ts → handleChatMessage()
|
||||
- oh-my-opencode 구조: src/hooks/claude-code-hooks/index.ts → createClaudeCodeHooksHook()
|
||||
- 첫 메시지 skip 로직: title generation을 위해 UserPromptSubmit hooks 실행 안 함
|
||||
|
||||
소요 시간: ~8분
|
||||
|
||||
---
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.7",
|
||||
"version": "0.3.0",
|
||||
"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.5.0",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"tree-sitter-wasms": "^0.1.12",
|
||||
"web-tree-sitter": "^0.24.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": {
|
||||
@@ -58,6 +59,7 @@
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"@ast-grep/cli",
|
||||
"@ast-grep/napi"
|
||||
"@ast-grep/napi",
|
||||
"@code-yeongyu/comment-checker"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import * as z from "zod"
|
||||
import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
|
||||
|
||||
const SCHEMA_OUTPUT_PATH = "dist/oh-my-opencode.schema.json"
|
||||
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
|
||||
|
||||
async function main() {
|
||||
console.log("Generating JSON Schema...")
|
||||
@@ -14,7 +14,7 @@ async function main() {
|
||||
|
||||
const finalSchema = {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json",
|
||||
$id: "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
|
||||
title: "Oh My OpenCode Configuration",
|
||||
description: "Configuration schema for oh-my-opencode plugin",
|
||||
...jsonSchema,
|
||||
|
||||
@@ -78,13 +78,44 @@ async function gitTagAndRelease(newVersion: string, changelog: string): Promise<
|
||||
await $`git config user.email "github-actions[bot]@users.noreply.github.com"`
|
||||
await $`git config user.name "github-actions[bot]"`
|
||||
await $`git add package.json`
|
||||
await $`git commit -m "release: v${newVersion}"`
|
||||
await $`git tag v${newVersion}`
|
||||
|
||||
// Commit only if there are staged changes (idempotent)
|
||||
const hasStagedChanges = await $`git diff --cached --quiet`.nothrow()
|
||||
if (hasStagedChanges.exitCode !== 0) {
|
||||
await $`git commit -m "release: v${newVersion}"`
|
||||
} else {
|
||||
console.log("No changes to commit (version already updated)")
|
||||
}
|
||||
|
||||
// Tag only if it doesn't exist (idempotent)
|
||||
const tagExists = await $`git rev-parse v${newVersion}`.nothrow()
|
||||
if (tagExists.exitCode !== 0) {
|
||||
await $`git tag v${newVersion}`
|
||||
} else {
|
||||
console.log(`Tag v${newVersion} already exists`)
|
||||
}
|
||||
|
||||
// Push (idempotent - git push is already idempotent)
|
||||
await $`git push origin HEAD --tags`
|
||||
|
||||
// Create release only if it doesn't exist (idempotent)
|
||||
console.log("\nCreating GitHub release...")
|
||||
const releaseNotes = changelog || "No notable changes"
|
||||
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
|
||||
const releaseExists = await $`gh release view v${newVersion}`.nothrow()
|
||||
if (releaseExists.exitCode !== 0) {
|
||||
await $`gh release create v${newVersion} --title "v${newVersion}" --notes ${releaseNotes}`
|
||||
} else {
|
||||
console.log(`Release v${newVersion} already exists`)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkVersionExists(version: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/${version}`)
|
||||
return res.ok
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -92,6 +123,11 @@ async function main() {
|
||||
const newVersion = versionOverride || (bump ? bumpVersion(previous, bump) : bumpVersion(previous, "patch"))
|
||||
console.log(`New version: ${newVersion}\n`)
|
||||
|
||||
if (await checkVersionExists(newVersion)) {
|
||||
console.log(`Version ${newVersion} already exists on npm. Skipping publish.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
await updatePackageVersion(newVersion)
|
||||
const changelog = await generateChangelog(previous)
|
||||
await buildAndPublish()
|
||||
|
||||
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
|
||||
}
|
||||
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
|
||||
summarize: (opts: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data
|
||||
if (!Array.isArray(data)) return null
|
||||
|
||||
const reversed = [...data].reverse()
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
if (!last) return null
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
|
||||
if (providerID && modelID) {
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).tui.submitPrompt({ query: { directory } })
|
||||
} catch {}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
} catch {}
|
||||
}
|
||||
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
||||
const autoCompactState = createAutoCompactState()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
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) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
if (errorData?.providerID && errorData?.modelID) {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID: errorData.providerID, modelID: errorData.modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
if (!lastAssistant) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistant.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAssistant.modelID || !lastAssistant.providerID) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
|
||||
interface AnthropicErrorData {
|
||||
type: "error"
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
const TOKEN_LIMIT_PATTERNS = [
|
||||
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
|
||||
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
|
||||
/(\d+).*?tokens.*?limit.*?(\d+)/i,
|
||||
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
|
||||
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
|
||||
]
|
||||
|
||||
const TOKEN_LIMIT_KEYWORDS = [
|
||||
"prompt is too long",
|
||||
"is too long",
|
||||
"context_length_exceeded",
|
||||
"max_tokens",
|
||||
"token limit",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
]
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const num1 = parseInt(match[1], 10)
|
||||
const num2 = parseInt(match[2], 10)
|
||||
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (isTokenLimitError(err)) {
|
||||
const tokens = extractTokensFromMessage(err)
|
||||
return {
|
||||
currentTokens: tokens?.current ?? 0,
|
||||
maxTokens: tokens?.max ?? 0,
|
||||
errorType: "token_limit_exceeded_string",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!err || typeof err !== "object") return null
|
||||
|
||||
const errObj = err as Record<string, unknown>
|
||||
|
||||
const dataObj = errObj.data as Record<string, unknown> | undefined
|
||||
const responseBody = dataObj?.responseBody
|
||||
const errorMessage = errObj.message as string | undefined
|
||||
const errorData = errObj.error as Record<string, unknown> | undefined
|
||||
const nestedError = errorData?.error as Record<string, unknown> | undefined
|
||||
|
||||
const textSources: string[] = []
|
||||
|
||||
if (typeof responseBody === "string") textSources.push(responseBody)
|
||||
if (typeof errorMessage === "string") textSources.push(errorMessage)
|
||||
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
|
||||
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
|
||||
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
|
||||
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
|
||||
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
|
||||
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
|
||||
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
|
||||
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
|
||||
|
||||
if (textSources.length === 0) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(errObj)
|
||||
if (isTokenLimitError(jsonStr)) {
|
||||
textSources.push(jsonStr)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const combinedText = textSources.join(" ")
|
||||
if (!isTokenLimitError(combinedText)) return null
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
const dataMatch = responseBody.match(pattern)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
|
||||
const message = jsonData.error?.message || ""
|
||||
const tokens = extractTokensFromMessage(message)
|
||||
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
requestId: jsonData.request_id,
|
||||
errorType: jsonData.error?.type || "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const bedrockJson = JSON.parse(responseBody)
|
||||
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "bedrock_input_too_long",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const text of textSources) {
|
||||
const tokens = extractTokensFromMessage(text)
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
errorType: "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTokenLimitError(combinedText)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "token_limit_exceeded_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface ParsedTokenLimitError {
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
requestId?: string
|
||||
errorType: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
}
|
||||
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 }
|
||||
}
|
||||
210
src/hooks/comment-checker/cli.ts
Normal file
210
src/hooks/comment-checker/cli.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { spawn } from "bun"
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import * as fs from "fs"
|
||||
import { getCachedBinaryPath, ensureCommentCheckerBinary } from "./downloader"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:cli] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
const cliDir = dirname(cliPkgPath)
|
||||
const binaryPath = join(cliDir, "bin", binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("found binary in main package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
debugLog("no binary found in known locations")
|
||||
return null
|
||||
}
|
||||
|
||||
// Cached resolved path
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
|
||||
/**
|
||||
* Asynchronously get comment-checker binary path.
|
||||
* Will trigger lazy download if binary not found.
|
||||
*/
|
||||
export async function getCommentCheckerPath(): Promise<string | null> {
|
||||
// Return cached path if already resolved
|
||||
if (resolvedCliPath !== null) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
// Return existing promise if initialization is in progress
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
// First try sync path resolution
|
||||
const syncPath = findCommentCheckerPathSync()
|
||||
if (syncPath && existsSync(syncPath)) {
|
||||
resolvedCliPath = syncPath
|
||||
debugLog("using sync-resolved path:", syncPath)
|
||||
return syncPath
|
||||
}
|
||||
|
||||
// Lazy download if not found
|
||||
debugLog("triggering lazy download...")
|
||||
const downloadedPath = await ensureCommentCheckerBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
debugLog("using downloaded path:", downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
debugLog("no binary available")
|
||||
return null
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously get comment-checker path (no download).
|
||||
* Returns cached path or searches known locations.
|
||||
*/
|
||||
export function getCommentCheckerPathSync(): string | null {
|
||||
return resolvedCliPath ?? findCommentCheckerPathSync()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start background initialization.
|
||||
* Call this early to trigger download while other init happens.
|
||||
*/
|
||||
export function startBackgroundInit(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getCommentCheckerPath()
|
||||
initPromise.then(path => {
|
||||
debugLog("background init complete:", path || "no binary")
|
||||
}).catch(err => {
|
||||
debugLog("background init error:", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy export for backwards compatibility (sync, no download)
|
||||
export const COMMENT_CHECKER_CLI_PATH = findCommentCheckerPathSync()
|
||||
|
||||
export interface HookInput {
|
||||
session_id: string
|
||||
tool_name: string
|
||||
transcript_path: string
|
||||
cwd: string
|
||||
hook_event_name: string
|
||||
tool_input: {
|
||||
file_path?: string
|
||||
content?: string
|
||||
old_string?: string
|
||||
new_string?: string
|
||||
edits?: Array<{ old_string: string; new_string: string }>
|
||||
}
|
||||
tool_response?: unknown
|
||||
}
|
||||
|
||||
export interface CheckResult {
|
||||
hasComments: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Run comment-checker CLI with given input.
|
||||
* @param input Hook input to check
|
||||
* @param cliPath Optional explicit path to CLI binary
|
||||
*/
|
||||
export async function runCommentChecker(input: HookInput, cliPath?: string): Promise<CheckResult> {
|
||||
const binaryPath = cliPath ?? resolvedCliPath ?? COMMENT_CHECKER_CLI_PATH
|
||||
|
||||
if (!binaryPath) {
|
||||
debugLog("comment-checker binary not found")
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
|
||||
if (!existsSync(binaryPath)) {
|
||||
debugLog("comment-checker binary does not exist:", binaryPath)
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
|
||||
const jsonInput = JSON.stringify(input)
|
||||
debugLog("running comment-checker with input:", jsonInput.substring(0, 200))
|
||||
|
||||
try {
|
||||
const proc = spawn([binaryPath], {
|
||||
stdin: "pipe",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
// Write JSON to stdin
|
||||
proc.stdin.write(jsonInput)
|
||||
proc.stdin.end()
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
debugLog("exit code:", exitCode, "stdout length:", stdout.length, "stderr length:", stderr.length)
|
||||
|
||||
if (exitCode === 0) {
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
|
||||
if (exitCode === 2) {
|
||||
// Comments detected - message is in stderr
|
||||
return { hasComments: true, message: stderr }
|
||||
}
|
||||
|
||||
// Error case
|
||||
debugLog("unexpected exit code:", exitCode, "stderr:", stderr)
|
||||
return { hasComments: false, message: "" }
|
||||
} catch (err) {
|
||||
debugLog("failed to run comment-checker:", err)
|
||||
return { hasComments: false, message: "" }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI is available (sync check, no download).
|
||||
*/
|
||||
export function isCliAvailable(): boolean {
|
||||
const path = getCommentCheckerPathSync()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLI will be available (async, may trigger download).
|
||||
*/
|
||||
export async function ensureCliAvailable(): Promise<boolean> {
|
||||
const path = await getCommentCheckerPath()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
@@ -1,117 +1,3 @@
|
||||
import type { LanguageConfig } from "./types"
|
||||
|
||||
export const EXTENSION_TO_LANGUAGE: Record<string, string> = {
|
||||
py: "python",
|
||||
js: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "tsx",
|
||||
go: "golang",
|
||||
java: "java",
|
||||
kt: "kotlin",
|
||||
scala: "scala",
|
||||
c: "c",
|
||||
h: "c",
|
||||
cpp: "cpp",
|
||||
cc: "cpp",
|
||||
cxx: "cpp",
|
||||
hpp: "cpp",
|
||||
rs: "rust",
|
||||
rb: "ruby",
|
||||
sh: "bash",
|
||||
bash: "bash",
|
||||
cs: "csharp",
|
||||
swift: "swift",
|
||||
ex: "elixir",
|
||||
exs: "elixir",
|
||||
lua: "lua",
|
||||
php: "php",
|
||||
ml: "ocaml",
|
||||
mli: "ocaml",
|
||||
sql: "sql",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
css: "css",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
hcl: "hcl",
|
||||
tf: "hcl",
|
||||
dockerfile: "dockerfile",
|
||||
proto: "protobuf",
|
||||
svelte: "svelte",
|
||||
elm: "elm",
|
||||
groovy: "groovy",
|
||||
cue: "cue",
|
||||
}
|
||||
|
||||
export const QUERY_TEMPLATES: Record<string, string> = {
|
||||
python: "(comment) @comment",
|
||||
javascript: "(comment) @comment",
|
||||
typescript: "(comment) @comment",
|
||||
tsx: "(comment) @comment",
|
||||
golang: "(comment) @comment",
|
||||
rust: `
|
||||
(line_comment) @comment
|
||||
(block_comment) @comment
|
||||
`,
|
||||
kotlin: `
|
||||
(line_comment) @comment
|
||||
(multiline_comment) @comment
|
||||
`,
|
||||
java: `
|
||||
(line_comment) @comment
|
||||
(block_comment) @comment
|
||||
`,
|
||||
c: "(comment) @comment",
|
||||
cpp: "(comment) @comment",
|
||||
csharp: "(comment) @comment",
|
||||
ruby: "(comment) @comment",
|
||||
bash: "(comment) @comment",
|
||||
swift: "(comment) @comment",
|
||||
elixir: "(comment) @comment",
|
||||
lua: "(comment) @comment",
|
||||
php: "(comment) @comment",
|
||||
ocaml: "(comment) @comment",
|
||||
sql: "(comment) @comment",
|
||||
html: "(comment) @comment",
|
||||
css: "(comment) @comment",
|
||||
yaml: "(comment) @comment",
|
||||
toml: "(comment) @comment",
|
||||
hcl: "(comment) @comment",
|
||||
dockerfile: "(comment) @comment",
|
||||
protobuf: "(comment) @comment",
|
||||
svelte: "(comment) @comment",
|
||||
elm: "(comment) @comment",
|
||||
groovy: "(comment) @comment",
|
||||
cue: "(comment) @comment",
|
||||
scala: "(comment) @comment",
|
||||
}
|
||||
|
||||
export const DOCSTRING_QUERIES: Record<string, string> = {
|
||||
python: `
|
||||
(module . (expression_statement (string) @docstring))
|
||||
(class_definition body: (block . (expression_statement (string) @docstring)))
|
||||
(function_definition body: (block . (expression_statement (string) @docstring)))
|
||||
`,
|
||||
javascript: `
|
||||
(comment) @jsdoc
|
||||
(#match? @jsdoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
typescript: `
|
||||
(comment) @jsdoc
|
||||
(#match? @jsdoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
tsx: `
|
||||
(comment) @jsdoc
|
||||
(#match? @jsdoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
java: `
|
||||
(comment) @javadoc
|
||||
(#match? @javadoc "^/\\\\*\\\\*")
|
||||
`,
|
||||
}
|
||||
|
||||
export const BDD_KEYWORDS = new Set([
|
||||
"given",
|
||||
"when",
|
||||
@@ -186,19 +72,12 @@ PRIORITY-BASED ACTION GUIDELINES:
|
||||
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
|
||||
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
|
||||
|
||||
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
|
||||
is a code smell. If you need separators, your file is too long or poorly organized.
|
||||
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
|
||||
|
||||
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
|
||||
Review in the above priority order and take the corresponding action EVERY TIME this appears.
|
||||
|
||||
Detected comments/docstrings:
|
||||
`
|
||||
|
||||
export function getLanguageByExtension(filePath: string): string | null {
|
||||
const lastDot = filePath.lastIndexOf(".")
|
||||
if (lastDot === -1) {
|
||||
const baseName = filePath.split("/").pop()?.toLowerCase()
|
||||
if (baseName === "dockerfile") return "dockerfile"
|
||||
return null
|
||||
}
|
||||
const ext = filePath.slice(lastDot + 1).toLowerCase()
|
||||
return EXTENSION_TO_LANGUAGE[ext] ?? null
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { CommentInfo, CommentType } from "./types"
|
||||
import { getLanguageByExtension, QUERY_TEMPLATES, DOCSTRING_QUERIES } from "./constants"
|
||||
|
||||
export function isSupportedFile(filePath: string): boolean {
|
||||
return getLanguageByExtension(filePath) !== null
|
||||
}
|
||||
|
||||
function determineCommentType(text: string, nodeType: string): CommentType {
|
||||
const stripped = text.trim()
|
||||
|
||||
if (nodeType === "line_comment") {
|
||||
return "line"
|
||||
}
|
||||
if (nodeType === "block_comment" || nodeType === "multiline_comment") {
|
||||
return "block"
|
||||
}
|
||||
|
||||
if (stripped.startsWith('"""') || stripped.startsWith("'''")) {
|
||||
return "docstring"
|
||||
}
|
||||
|
||||
if (stripped.startsWith("//") || stripped.startsWith("#")) {
|
||||
return "line"
|
||||
}
|
||||
|
||||
if (stripped.startsWith("/*") || stripped.startsWith("<!--") || stripped.startsWith("--")) {
|
||||
return "block"
|
||||
}
|
||||
|
||||
return "line"
|
||||
}
|
||||
|
||||
export async function detectComments(
|
||||
filePath: string,
|
||||
content: string,
|
||||
includeDocstrings = true
|
||||
): Promise<CommentInfo[]> {
|
||||
const langName = getLanguageByExtension(filePath)
|
||||
if (!langName) {
|
||||
return []
|
||||
}
|
||||
|
||||
const queryPattern = QUERY_TEMPLATES[langName]
|
||||
if (!queryPattern) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const Parser = (await import("web-tree-sitter")).default
|
||||
await Parser.init()
|
||||
|
||||
const parser = new Parser()
|
||||
|
||||
let wasmPath: string
|
||||
try {
|
||||
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${langName}.wasm`)
|
||||
wasmPath = wasmModule.default
|
||||
} catch {
|
||||
const languageMap: Record<string, string> = {
|
||||
golang: "go",
|
||||
csharp: "c_sharp",
|
||||
cpp: "cpp",
|
||||
}
|
||||
const mappedLang = languageMap[langName] || langName
|
||||
try {
|
||||
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${mappedLang}.wasm`)
|
||||
wasmPath = wasmModule.default
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const language = await Parser.Language.load(wasmPath)
|
||||
parser.setLanguage(language)
|
||||
|
||||
const tree = parser.parse(content)
|
||||
const comments: CommentInfo[] = []
|
||||
|
||||
const query = language.query(queryPattern)
|
||||
const matches = query.matches(tree.rootNode)
|
||||
|
||||
for (const match of matches) {
|
||||
for (const capture of match.captures) {
|
||||
const node = capture.node
|
||||
const text = node.text
|
||||
const lineNumber = node.startPosition.row + 1
|
||||
|
||||
const commentType = determineCommentType(text, node.type)
|
||||
const isDocstring = commentType === "docstring"
|
||||
|
||||
if (isDocstring && !includeDocstrings) {
|
||||
continue
|
||||
}
|
||||
|
||||
comments.push({
|
||||
text,
|
||||
lineNumber,
|
||||
filePath,
|
||||
commentType,
|
||||
isDocstring,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (includeDocstrings) {
|
||||
const docQuery = DOCSTRING_QUERIES[langName]
|
||||
if (docQuery) {
|
||||
try {
|
||||
const docQueryObj = language.query(docQuery)
|
||||
const docMatches = docQueryObj.matches(tree.rootNode)
|
||||
|
||||
for (const match of docMatches) {
|
||||
for (const capture of match.captures) {
|
||||
const node = capture.node
|
||||
const text = node.text
|
||||
const lineNumber = node.startPosition.row + 1
|
||||
|
||||
const alreadyAdded = comments.some(
|
||||
(c) => c.lineNumber === lineNumber && c.text === text
|
||||
)
|
||||
if (!alreadyAdded) {
|
||||
comments.push({
|
||||
text,
|
||||
lineNumber,
|
||||
filePath,
|
||||
commentType: "docstring",
|
||||
isDocstring: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
comments.sort((a, b) => a.lineNumber - b.lineNumber)
|
||||
|
||||
return comments
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
210
src/hooks/comment-checker/downloader.ts
Normal file
210
src/hooks/comment-checker/downloader.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:downloader] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const REPO = "code-yeongyu/go-claude-code-comment-checker"
|
||||
|
||||
interface PlatformInfo {
|
||||
os: string
|
||||
arch: string
|
||||
ext: "tar.gz" | "zip"
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { os: "darwin", arch: "arm64", ext: "tar.gz" },
|
||||
"darwin-x64": { os: "darwin", arch: "amd64", ext: "tar.gz" },
|
||||
"linux-arm64": { os: "linux", arch: "arm64", ext: "tar.gz" },
|
||||
"linux-x64": { os: "linux", arch: "amd64", ext: "tar.gz" },
|
||||
"win32-x64": { os: "windows", arch: "amd64", ext: "zip" },
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cache directory for oh-my-opencode binaries.
|
||||
* Follows XDG Base Directory Specification.
|
||||
*/
|
||||
export function getCacheDir(): string {
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the binary name based on platform.
|
||||
*/
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the cached binary path if it exists.
|
||||
*/
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
const binaryPath = join(getCacheDir(), getBinaryName())
|
||||
return existsSync(binaryPath) ? binaryPath : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the version from the installed @code-yeongyu/comment-checker package.
|
||||
*/
|
||||
function getPackageVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@code-yeongyu/comment-checker/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
// Fallback to hardcoded version if package not found
|
||||
return "0.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract tar.gz archive using system tar command.
|
||||
*/
|
||||
async function extractTarGz(archivePath: string, destDir: string): Promise<void> {
|
||||
debugLog("Extracting tar.gz:", archivePath, "to", destDir)
|
||||
|
||||
const proc = spawn(["tar", "-xzf", archivePath, "-C", destDir], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract zip archive using system commands.
|
||||
*/
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
debugLog("Extracting zip:", archivePath, "to", destDir)
|
||||
|
||||
const proc = process.platform === "win32"
|
||||
? spawn(["powershell", "-command", `Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
: spawn(["unzip", "-o", archivePath, "-d", destDir], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Download the comment-checker binary from GitHub Releases.
|
||||
* Returns the path to the downloaded binary, or null on failure.
|
||||
*/
|
||||
export async function downloadCommentChecker(): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
debugLog(`Unsupported platform: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
// Already exists in cache
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("Binary already cached at:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const version = getPackageVersion()
|
||||
const { os, arch, ext } = platformInfo
|
||||
const assetName = `comment-checker_v${version}_${os}_${arch}.${ext}`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/v${version}/${assetName}`
|
||||
|
||||
debugLog(`Downloading from: ${downloadUrl}`)
|
||||
console.log(`[oh-my-opencode] Downloading comment-checker binary...`)
|
||||
|
||||
try {
|
||||
// Ensure cache directory exists
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Download with fetch() - Bun handles redirects automatically
|
||||
const response = await fetch(downloadUrl, { redirect: "follow" })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
await Bun.write(archivePath, arrayBuffer)
|
||||
|
||||
debugLog(`Downloaded archive to: ${archivePath}`)
|
||||
|
||||
// Extract based on file type
|
||||
if (ext === "tar.gz") {
|
||||
await extractTarGz(archivePath, cacheDir)
|
||||
} else {
|
||||
await extractZip(archivePath, cacheDir)
|
||||
}
|
||||
|
||||
// Clean up archive
|
||||
if (existsSync(archivePath)) {
|
||||
unlinkSync(archivePath)
|
||||
}
|
||||
|
||||
// Set execute permission on Unix
|
||||
if (process.platform !== "win32" && existsSync(binaryPath)) {
|
||||
chmodSync(binaryPath, 0o755)
|
||||
}
|
||||
|
||||
debugLog(`Successfully downloaded binary to: ${binaryPath}`)
|
||||
console.log(`[oh-my-opencode] comment-checker binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
|
||||
} catch (err) {
|
||||
debugLog(`Failed to download: ${err}`)
|
||||
console.error(`[oh-my-opencode] Failed to download comment-checker: ${err instanceof Error ? err.message : err}`)
|
||||
console.error(`[oh-my-opencode] Comment checking disabled.`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the comment-checker binary is available.
|
||||
* First checks cache, then downloads if needed.
|
||||
* Returns the binary path or null if unavailable.
|
||||
*/
|
||||
export async function ensureCommentCheckerBinary(): Promise<string | null> {
|
||||
// Check cache first
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("Using cached binary:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// Download if not cached
|
||||
return downloadCommentChecker()
|
||||
}
|
||||
@@ -1,11 +1,24 @@
|
||||
import type { PendingCall, FileComments } from "./types"
|
||||
import { detectComments, isSupportedFile } from "./detector"
|
||||
import { applyFilters } from "./filters"
|
||||
import { formatHookMessage } from "./output"
|
||||
import type { PendingCall } from "./types"
|
||||
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
|
||||
|
||||
import * as fs from "fs"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = "/tmp/comment-checker-debug.log"
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCalls = new Map<string, PendingCall>()
|
||||
const PENDING_CALL_TTL = 60_000
|
||||
|
||||
let cliPathPromise: Promise<string | null> | null = null
|
||||
|
||||
function cleanupOldPendingCalls(): void {
|
||||
const now = Date.now()
|
||||
for (const [callID, call] of pendingCalls) {
|
||||
@@ -18,30 +31,50 @@ function cleanupOldPendingCalls(): void {
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
|
||||
export function createCommentCheckerHooks() {
|
||||
debugLog("createCommentCheckerHooks called")
|
||||
|
||||
// Start background CLI initialization (may trigger lazy download)
|
||||
startBackgroundInit()
|
||||
cliPathPromise = getCommentCheckerPath()
|
||||
cliPathPromise.then(path => {
|
||||
debugLog("CLI path resolved:", path || "disabled (no binary)")
|
||||
}).catch(err => {
|
||||
debugLog("CLI path resolution error:", err)
|
||||
})
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.before:", { tool: input.tool, callID: input.callID, args: output.args })
|
||||
|
||||
const toolLower = input.tool.toLowerCase()
|
||||
if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") {
|
||||
debugLog("skipping non-write/edit tool:", toolLower)
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.file_path) as string | undefined
|
||||
const filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined
|
||||
const content = output.args.content as string | undefined
|
||||
const oldString = output.args.oldString ?? output.args.old_string as string | undefined
|
||||
const newString = output.args.newString ?? output.args.new_string as string | undefined
|
||||
const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined
|
||||
|
||||
debugLog("extracted filePath:", filePath)
|
||||
|
||||
if (!filePath) {
|
||||
debugLog("no filePath found")
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupportedFile(filePath)) {
|
||||
return
|
||||
}
|
||||
|
||||
debugLog("registering pendingCall:", { callID: input.callID, filePath, tool: toolLower })
|
||||
pendingCalls.set(input.callID, {
|
||||
filePath,
|
||||
content,
|
||||
oldString: oldString as string | undefined,
|
||||
newString: newString as string | undefined,
|
||||
edits,
|
||||
tool: toolLower as "write" | "edit" | "multiedit",
|
||||
sessionID: input.sessionID,
|
||||
timestamp: Date.now(),
|
||||
@@ -52,46 +85,79 @@ export function createCommentCheckerHooks() {
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
|
||||
|
||||
const pendingCall = pendingCalls.get(input.callID)
|
||||
if (!pendingCall) {
|
||||
debugLog("no pendingCall found for:", input.callID)
|
||||
return
|
||||
}
|
||||
|
||||
pendingCalls.delete(input.callID)
|
||||
debugLog("processing pendingCall:", pendingCall)
|
||||
|
||||
if (output.output.toLowerCase().includes("error")) {
|
||||
// Only skip if the output indicates a tool execution failure
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const isToolFailure =
|
||||
outputLower.includes("error:") ||
|
||||
outputLower.includes("failed to") ||
|
||||
outputLower.includes("could not") ||
|
||||
outputLower.startsWith("error")
|
||||
|
||||
if (isToolFailure) {
|
||||
debugLog("skipping due to tool failure in output")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
let content: string
|
||||
|
||||
if (pendingCall.content) {
|
||||
content = pendingCall.content
|
||||
} else {
|
||||
const file = Bun.file(pendingCall.filePath)
|
||||
content = await file.text()
|
||||
}
|
||||
|
||||
const rawComments = await detectComments(pendingCall.filePath, content)
|
||||
const filteredComments = applyFilters(rawComments)
|
||||
|
||||
if (filteredComments.length === 0) {
|
||||
// Wait for CLI path resolution
|
||||
const cliPath = await cliPathPromise
|
||||
|
||||
if (!cliPath || !existsSync(cliPath)) {
|
||||
// CLI not available - silently skip comment checking
|
||||
debugLog("CLI not available, skipping comment check")
|
||||
return
|
||||
}
|
||||
|
||||
const fileComments: FileComments[] = [
|
||||
{
|
||||
filePath: pendingCall.filePath,
|
||||
comments: filteredComments,
|
||||
},
|
||||
]
|
||||
|
||||
const message = formatHookMessage(fileComments)
|
||||
output.output += `\n\n${message}`
|
||||
} catch {}
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function processWithCli(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
pendingCall: PendingCall,
|
||||
output: { output: string },
|
||||
cliPath: string
|
||||
): Promise<void> {
|
||||
debugLog("using CLI mode with path:", cliPath)
|
||||
|
||||
const hookInput: HookInput = {
|
||||
session_id: pendingCall.sessionID,
|
||||
tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1),
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: {
|
||||
file_path: pendingCall.filePath,
|
||||
content: pendingCall.content,
|
||||
old_string: pendingCall.oldString,
|
||||
new_string: pendingCall.newString,
|
||||
edits: pendingCall.edits,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCommentChecker(hookInput, cliPath)
|
||||
|
||||
if (result.hasComments && result.message) {
|
||||
debugLog("CLI detected comments, appending message")
|
||||
output.output += `\n\n${result.message}`
|
||||
} else {
|
||||
debugLog("CLI: no comments detected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,15 +9,12 @@ export interface CommentInfo {
|
||||
metadata?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface LanguageConfig {
|
||||
extensions: string[]
|
||||
commentQuery: string
|
||||
docstringQuery?: string
|
||||
}
|
||||
|
||||
export interface PendingCall {
|
||||
filePath: string
|
||||
content?: string
|
||||
oldString?: string
|
||||
newString?: string
|
||||
edits?: Array<{ old_string: string; new_string: string }>
|
||||
tool: "write" | "edit" | "multiedit"
|
||||
sessionID: string
|
||||
timestamp: number
|
||||
|
||||
9
src/hooks/directory-agents-injector/constants.ts
Normal file
9
src/hooks/directory-agents-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 AGENTS_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-agents",
|
||||
);
|
||||
export const AGENTS_FILENAME = "AGENTS.md";
|
||||
126
src/hooks/directory-agents-injector/index.ts
Normal file
126
src/hooks/directory-agents-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 { AGENTS_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 createDirectoryAgentsInjectorHook(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 findAgentsMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
|
||||
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 agentsPaths = findAgentsMdUp(dir);
|
||||
|
||||
const toInject: { path: string; content: string }[] = [];
|
||||
|
||||
for (const agentsPath of agentsPaths) {
|
||||
const agentsDir = dirname(agentsPath);
|
||||
if (cache.has(agentsDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
toInject.push({ path: agentsPath, content });
|
||||
cache.add(agentsDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
for (const { path, content } of toInject) {
|
||||
output.output += `\n\n[Directory Context: ${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-agents-injector/storage.ts
Normal file
48
src/hooks/directory-agents-injector/storage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { AGENTS_INJECTOR_STORAGE } from "./constants";
|
||||
import type { InjectedPathsData } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(AGENTS_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(AGENTS_INJECTOR_STORAGE)) {
|
||||
mkdirSync(AGENTS_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-agents-injector/types.ts
Normal file
5
src/hooks/directory-agents-injector/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
27
src/hooks/empty-task-response-detector.ts
Normal file
27
src/hooks/empty-task-response-detector.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const EMPTY_RESPONSE_WARNING = `[Task Empty Response Warning]
|
||||
|
||||
Task invocation completed but returned no response. This indicates the agent either:
|
||||
- Failed to execute properly
|
||||
- Did not terminate correctly
|
||||
- Returned an empty result
|
||||
|
||||
Note: The call has already completed - you are NOT waiting for a response. Proceed accordingly.`
|
||||
|
||||
export function createEmptyTaskResponseDetectorHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool !== "Task") return
|
||||
|
||||
const responseText = output.output?.trim() ?? ""
|
||||
|
||||
if (responseText === "") {
|
||||
output.output = EMPTY_RESPONSE_WARNING
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const BLOCKED_MESSAGE =
|
||||
"Error: [BLOCKED] grep has no timeout and can freeze the system. " +
|
||||
"It is permanently disabled. Use 'safe_grep' instead."
|
||||
|
||||
export function createGrepBlocker(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
_output: { args: unknown }
|
||||
) => {
|
||||
if (input.tool === "grep") {
|
||||
throw new Error(BLOCKED_MESSAGE)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
131
src/hooks/grep-output-truncator.ts
Normal file
131
src/hooks/grep-output-truncator.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4
|
||||
const TARGET_MAX_TOKENS = 50_000
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant"
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageWrapper {
|
||||
info: { role: string } & Partial<AssistantMessageInfo>
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
|
||||
}
|
||||
|
||||
function truncateToTokenLimit(output: string, maxTokens: number): { result: string; truncated: boolean } {
|
||||
const currentTokens = estimateTokens(output)
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
return { result: output, truncated: false }
|
||||
}
|
||||
|
||||
const lines = output.split("\n")
|
||||
|
||||
if (lines.length <= 3) {
|
||||
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE
|
||||
return {
|
||||
result: output.slice(0, maxChars) + "\n\n[Output truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
const headerLines = lines.slice(0, 3)
|
||||
const contentLines = lines.slice(3)
|
||||
|
||||
const headerText = headerLines.join("\n")
|
||||
const headerTokens = estimateTokens(headerText)
|
||||
const availableTokens = maxTokens - headerTokens - 50
|
||||
|
||||
if (availableTokens <= 0) {
|
||||
return {
|
||||
result: headerText + "\n\n[Content truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
let resultLines: string[] = []
|
||||
let currentTokenCount = 0
|
||||
|
||||
for (const line of contentLines) {
|
||||
const lineTokens = estimateTokens(line + "\n")
|
||||
if (currentTokenCount + lineTokens > availableTokens) {
|
||||
break
|
||||
}
|
||||
resultLines.push(line)
|
||||
currentTokenCount += lineTokens
|
||||
}
|
||||
|
||||
const truncatedContent = [...headerLines, ...resultLines].join("\n")
|
||||
const removedCount = contentLines.length - resultLines.length
|
||||
|
||||
return {
|
||||
result: truncatedContent + `\n\n[${removedCount} more lines truncated due to context window limit]`,
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createGrepOutputTruncatorHook(ctx: PluginInput) {
|
||||
const GREP_TOOLS = ["safe_grep", "Grep"]
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!GREP_TOOLS.includes(input.tool)) return
|
||||
|
||||
const { sessionID } = input
|
||||
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = (response.data ?? response) as MessageWrapper[]
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.map((m) => m.info as AssistantMessageInfo)
|
||||
|
||||
if (assistantMessages.length === 0) return
|
||||
|
||||
const totalInputTokens = assistantMessages.reduce((sum, m) => {
|
||||
const inputTokens = m.tokens?.input ?? 0
|
||||
const cacheReadTokens = m.tokens?.cache?.read ?? 0
|
||||
return sum + inputTokens + cacheReadTokens
|
||||
}, 0)
|
||||
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - totalInputTokens
|
||||
|
||||
const maxOutputTokens = Math.min(
|
||||
remainingTokens * 0.5,
|
||||
TARGET_MAX_TOKENS
|
||||
)
|
||||
|
||||
if (maxOutputTokens <= 0) {
|
||||
output.output = "[Output suppressed - context window exhausted]"
|
||||
return
|
||||
}
|
||||
|
||||
const { result, truncated } = truncateToTokenLimit(output.output, maxOutputTokens)
|
||||
if (truncated) {
|
||||
output.output = result
|
||||
}
|
||||
} catch {
|
||||
// Graceful degradation
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
export { createSessionNotification } from "./session-notification"
|
||||
export { createSessionRecoveryHook } from "./session-recovery"
|
||||
export { createCommentCheckerHooks } from "./comment-checker"
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||
export { createSessionNotification } from "./session-notification";
|
||||
export { createSessionRecoveryHook } from "./session-recovery";
|
||||
export { createCommentCheckerHooks } from "./comment-checker";
|
||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||
export { createDirectoryAgentsInjectorHook } from "./directory-agents-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";
|
||||
|
||||
10
src/hooks/session-recovery/constants.ts
Normal file
10
src/hooks/session-recovery/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||
export const META_TYPES = new Set(["step-start", "step-finish"])
|
||||
export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"])
|
||||
@@ -1,26 +1,23 @@
|
||||
/**
|
||||
* Session Recovery - Message State Error Recovery
|
||||
*
|
||||
* Handles FOUR specific scenarios:
|
||||
* 1. tool_use block exists without tool_result
|
||||
* - Recovery: inject tool_result with "cancelled" content
|
||||
*
|
||||
* 2. Thinking block order violation (first block must be thinking)
|
||||
* - Recovery: prepend empty thinking block
|
||||
*
|
||||
* 3. Thinking disabled but message contains thinking blocks
|
||||
* - Recovery: strip thinking/redacted_thinking blocks
|
||||
*
|
||||
* 4. Empty content message (non-empty content required)
|
||||
* - Recovery: delete the empty message via revert
|
||||
*/
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessagesWithThinkingBlocks,
|
||||
injectTextPart,
|
||||
prependThinkingPart,
|
||||
stripThinkingParts,
|
||||
} from "./storage"
|
||||
import type { MessageData } from "./types"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
|
||||
type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "empty_content_message"
|
||||
| null
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
@@ -37,11 +34,6 @@ interface ToolUsePart {
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
@@ -51,16 +43,15 @@ interface MessagePart {
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MessageData {
|
||||
info?: MessageInfo
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error.toLowerCase()
|
||||
const errorObj = error as { data?: { message?: string }; message?: string }
|
||||
return (errorObj.data?.message || errorObj.message || "").toLowerCase()
|
||||
const errorObj = error as {
|
||||
data?: { message?: string }
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
}
|
||||
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
@@ -70,7 +61,10 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (message.includes("thinking") && message.includes("first block")) {
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
@@ -110,7 +104,7 @@ async function recoverToolResultMissing(
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
// @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
body: { parts: toolResultParts },
|
||||
})
|
||||
|
||||
@@ -121,182 +115,77 @@ async function recoverToolResultMissing(
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrder(
|
||||
client: Client,
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
|
||||
|
||||
if (orphanMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingParts = failedAssistantMsg.parts || []
|
||||
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: patchedParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
messageID,
|
||||
parts: patchedParts,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
|
||||
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolation(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingParts = failedAssistantMsg.parts || []
|
||||
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
|
||||
|
||||
if (strippedParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: strippedParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
messageID,
|
||||
parts: strippedParts,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
const parentMsgID = failedAssistantMsg.info?.parentID
|
||||
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Revert to parent message (delete the empty message)
|
||||
const revertTargetID = parentMsgID || messageID
|
||||
|
||||
try {
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: revertTargetID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackRevertStrategy(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const parentMsgID = failedAssistantMsg.info?.parentID
|
||||
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
if (!msgs || msgs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let targetUserMsg: MessageData | null = null
|
||||
if (parentMsgID) {
|
||||
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
|
||||
}
|
||||
if (!targetUserMsg) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].info?.role === "user") {
|
||||
targetUserMsg = msgs[i]
|
||||
break
|
||||
}
|
||||
let anySuccess = false
|
||||
for (const messageID of orphanMessages) {
|
||||
if (prependThinkingPart(sessionID, messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUserMsg?.parts?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: targetUserMsg.info?.id ?? "" },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const textParts = targetUserMsg.parts
|
||||
.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
|
||||
|
||||
if (textParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: textParts },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
return true
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolation(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
||||
|
||||
if (messagesWithThinking.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of messagesWithThinking) {
|
||||
if (stripThinkingParts(messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const emptyMessageIDs = findEmptyMessages(sessionID)
|
||||
|
||||
if (emptyMessageIDs.length === 0) {
|
||||
const fallbackID = failedAssistantMsg.info?.id
|
||||
if (!fallbackID) return false
|
||||
return injectTextPart(sessionID, fallbackID, "(interrupted)")
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of emptyMessageIDs) {
|
||||
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
|
||||
// Reason: Function was defined but never called - no error recovery paths used it.
|
||||
// All error types have dedicated recovery functions (recoverToolResultMissing,
|
||||
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
const processingErrors = new Set<string>()
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
@@ -350,16 +239,14 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
empty_content_message: "Deleting empty message...",
|
||||
empty_content_message: "Fixing empty message...",
|
||||
}
|
||||
const toastTitle = toastTitles[errorType]
|
||||
const toastMessage = toastMessages[errorType]
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
title: toastTitles[errorType],
|
||||
message: toastMessages[errorType],
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
235
src/hooks/session-recovery/storage.ts
Normal file
235
src/hooks/session-recovery/storage.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
|
||||
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
|
||||
|
||||
export function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
|
||||
export function getMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) {
|
||||
return directPath
|
||||
}
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) {
|
||||
return sessionPath
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function readMessages(sessionID: string): StoredMessageMeta[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messages: StoredMessageMeta[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
messages.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export function readParts(messageID: string): StoredPart[] {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return []
|
||||
|
||||
const parts: StoredPart[] = []
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
parts.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
export function hasContent(part: StoredPart): boolean {
|
||||
if (THINKING_TYPES.has(part.type)) return false
|
||||
if (META_TYPES.has(part.type)) return false
|
||||
|
||||
if (part.type === "text") {
|
||||
const textPart = part as StoredTextPart
|
||||
return !!(textPart.text?.trim())
|
||||
}
|
||||
|
||||
if (part.type === "tool" || part.type === "tool_use") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (part.type === "tool_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function messageHasContent(messageID: string): boolean {
|
||||
const parts = readParts(messageID)
|
||||
return parts.some(hasContent)
|
||||
}
|
||||
|
||||
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const partId = generatePartId()
|
||||
const part: StoredTextPart = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
const emptyIds = findEmptyMessages(sessionID)
|
||||
return emptyIds.length > 0 ? emptyIds[0] : null
|
||||
}
|
||||
|
||||
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
if (hasThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (hasThinking && !firstIsThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const partId = `prt_0000000000_thinking`
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function stripThinkingParts(messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyRemoved = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
unlinkSync(filePath)
|
||||
anyRemoved = true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
82
src/hooks/session-recovery/types.ts
Normal file
82
src/hooks/session-recovery/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning"
|
||||
export type MetaPartType = "step-start" | "step-finish"
|
||||
export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result"
|
||||
|
||||
export interface StoredMessageMeta {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
parentID?: string
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface StoredTextPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
export interface StoredToolPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredReasoningPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StoredStepPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-start" | "step-finish"
|
||||
}
|
||||
|
||||
export type StoredPart = StoredTextPart | StoredToolPart | StoredReasoningPart | StoredStepPart | {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
info?: {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
parts?: Array<{
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
callID?: string
|
||||
}>
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -7,18 +7,13 @@ interface Todo {
|
||||
id: string
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO ENFORCEMENT]
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
|
||||
Your todo list is NOT complete. There are still incomplete tasks remaining.
|
||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||
|
||||
CRITICAL INSTRUCTION:
|
||||
- You MUST NOT stop working until ALL todos are marked as completed
|
||||
- Continue working on the next pending task immediately
|
||||
- Work honestly and diligently to finish every task
|
||||
- Do NOT ask for permission to continue - just proceed with the work
|
||||
- Mark each task as completed as soon as you finish it
|
||||
|
||||
Resume your work NOW.`
|
||||
- Proceed without asking for permission
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
|
||||
function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
@@ -113,7 +108,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${incomplete.length}/${todos.length} tasks remaining]`,
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
246
src/index.ts
246
src/index.ts
@@ -1,132 +1,195 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { createBuiltinAgents } from "./agents"
|
||||
import { createTodoContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook } from "./hooks"
|
||||
import { updateTerminalTitle } from "./features/terminal"
|
||||
import { builtinTools } from "./tools"
|
||||
import { createBuiltinMcps } from "./mcp"
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { createBuiltinAgents } from "./agents";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
createSessionRecoveryHook,
|
||||
createCommentCheckerHooks,
|
||||
createGrepOutputTruncatorHook,
|
||||
createDirectoryAgentsInjectorHook,
|
||||
createEmptyTaskResponseDetectorHook,
|
||||
createThinkModeHook,
|
||||
createClaudeCodeHooksHook,
|
||||
} 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";
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
const configPaths = [
|
||||
path.join(directory, "oh-my-opencode.json"),
|
||||
path.join(directory, ".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)
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
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 {}
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
return {};
|
||||
}
|
||||
|
||||
return result.data
|
||||
return result.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, use defaults
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx)
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx)
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx)
|
||||
const pluginConfig = loadPluginConfig(ctx.directory);
|
||||
|
||||
updateTerminalTitle({ sessionId: "main" })
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx);
|
||||
const commentChecker = createCommentCheckerHooks();
|
||||
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
|
||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
||||
const thinkMode = createThinkModeHook();
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory)
|
||||
|
||||
let mainSessionID: string | undefined
|
||||
let currentSessionID: string | undefined
|
||||
let currentSessionTitle: string | undefined
|
||||
updateTerminalTitle({ sessionId: "main" });
|
||||
|
||||
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
|
||||
)
|
||||
pluginConfig.agents,
|
||||
);
|
||||
const userAgents = loadUserAgents();
|
||||
const projectAgents = loadProjectAgents();
|
||||
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...config.agent,
|
||||
...agents,
|
||||
}
|
||||
};
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
grep: false,
|
||||
}
|
||||
};
|
||||
|
||||
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 todoContinuationEnforcer(input)
|
||||
await contextWindowMonitor.event(input)
|
||||
await claudeCodeHooks.event(input);
|
||||
await todoContinuationEnforcer(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await directoryAgentsInjector.event(input);
|
||||
await thinkMode.event(input);
|
||||
|
||||
const { event } = input
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const { event } = input;
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
|
||||
const sessionInfo = props?.info as
|
||||
| { 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.updated") {
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
|
||||
const sessionInfo = props?.info as
|
||||
| { 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
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined);
|
||||
setCurrentSession(undefined, undefined);
|
||||
updateTerminalTitle({
|
||||
sessionId: "main",
|
||||
status: "idle",
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const error = props?.error;
|
||||
|
||||
if (sessionRecovery.isRecoverableError(error)) {
|
||||
const messageInfo = {
|
||||
@@ -134,61 +197,80 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
error,
|
||||
};
|
||||
const recovered =
|
||||
await sessionRecovery.handleSessionRecovery(messageInfo);
|
||||
|
||||
if (recovered && sessionID && sessionID === getMainSessionID()) {
|
||||
await ctx.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
await sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
}
|
||||
|
||||
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) {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
if (sessionID && sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: sessionID,
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, _output) => {
|
||||
if (input.sessionID === mainSessionID) {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||
await commentChecker["tool.execute.before"](input, output);
|
||||
|
||||
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 contextWindowMonitor["tool.execute.after"](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 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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default OhMyOpenCodePlugin
|
||||
export default OhMyOpenCodePlugin;
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
@@ -196,4 +278,4 @@ export type {
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
} from "./config"
|
||||
} from "./config";
|
||||
|
||||
@@ -20,5 +20,3 @@ export function createBuiltinMcps(disabledMcps: McpName[] = []) {
|
||||
|
||||
return mcps
|
||||
}
|
||||
|
||||
export const builtinMcps = allBuiltinMcps
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,6 +1,15 @@
|
||||
import { spawn } from "bun"
|
||||
import { SG_CLI_PATH } from "./constants"
|
||||
import type { CliMatch, CliLanguage } from "./types"
|
||||
import { existsSync } from "fs"
|
||||
import {
|
||||
getSgCliPath,
|
||||
setSgCliPath,
|
||||
findSgCliPathSync,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_MAX_MATCHES,
|
||||
} from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliMatch, CliLanguage, SgResult } from "./types"
|
||||
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
@@ -12,7 +21,47 @@ export interface RunOptions {
|
||||
updateAll?: boolean
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
let resolvedCliPath: string | null = null
|
||||
let initPromise: Promise<string | null> | null = null
|
||||
|
||||
export async function getAstGrepPath(): Promise<string | null> {
|
||||
if (resolvedCliPath !== null && existsSync(resolvedCliPath)) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise
|
||||
}
|
||||
|
||||
initPromise = (async () => {
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath && existsSync(syncPath)) {
|
||||
resolvedCliPath = syncPath
|
||||
setSgCliPath(syncPath)
|
||||
return syncPath
|
||||
}
|
||||
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return downloadedPath
|
||||
}
|
||||
|
||||
return null
|
||||
})()
|
||||
|
||||
return initPromise
|
||||
}
|
||||
|
||||
export function startBackgroundInit(): void {
|
||||
if (!initPromise) {
|
||||
initPromise = getAstGrepPath()
|
||||
initPromise.catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
@@ -35,32 +84,147 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
const paths = options.paths && options.paths.length > 0 ? options.paths : ["."]
|
||||
args.push(...paths)
|
||||
|
||||
const proc = spawn([SG_CLI_PATH, ...args], {
|
||||
let cliPath = getSgCliPath()
|
||||
|
||||
if (!existsSync(cliPath) && cliPath !== "sg") {
|
||||
const downloadedPath = await getAstGrepPath()
|
||||
if (downloadedPath) {
|
||||
cliPath = downloadedPath
|
||||
}
|
||||
}
|
||||
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
let stdout: string
|
||||
let stderr: string
|
||||
let exitCode: number
|
||||
|
||||
try {
|
||||
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
stderr = await new Response(proc.stderr).text()
|
||||
exitCode = await proc.exited
|
||||
} catch (e) {
|
||||
const error = e as Error
|
||||
if (error.message?.includes("timeout")) {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "timeout",
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
const nodeError = e as NodeJS.ErrnoException
|
||||
if (
|
||||
nodeError.code === "ENOENT" ||
|
||||
nodeError.message?.includes("ENOENT") ||
|
||||
nodeError.message?.includes("not found")
|
||||
) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
return runSg(options)
|
||||
} else {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
`Auto-download failed. Manual install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error: `Failed to spawn ast-grep: ${error.message}`,
|
||||
}
|
||||
}
|
||||
|
||||
if (exitCode !== 0 && stdout.trim() === "") {
|
||||
if (stderr.includes("No files found")) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
throw new Error(stderr.trim())
|
||||
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
|
||||
}
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
let matches: CliMatch[] = []
|
||||
try {
|
||||
return JSON.parse(stdout) as CliMatch[]
|
||||
matches = JSON.parse(outputToProcess) as CliMatch[]
|
||||
} catch {
|
||||
return []
|
||||
if (outputTruncated) {
|
||||
try {
|
||||
const lastValidIndex = outputToProcess.lastIndexOf("}")
|
||||
if (lastValidIndex > 0) {
|
||||
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
|
||||
if (bracketIndex > 0) {
|
||||
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
|
||||
matches = JSON.parse(truncatedJson) as CliMatch[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "max_output_bytes",
|
||||
error: "Output too large and could not be parsed",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
|
||||
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
|
||||
|
||||
return {
|
||||
matches: finalMatches,
|
||||
totalMatches,
|
||||
truncated: outputTruncated || matchesTruncated,
|
||||
truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
export function isCliAvailable(): boolean {
|
||||
const path = findSgCliPathSync()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
export async function ensureCliAvailable(): Promise<boolean> {
|
||||
const path = await getAstGrepPath()
|
||||
return path !== null && existsSync(path)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { getCachedBinaryPath } from "./downloader"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function isValidBinary(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).size > 10000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
@@ -21,32 +30,37 @@ function getPlatformPackageName(): string | null {
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function findSgCliPath(): string {
|
||||
// 1. Try to find from @ast-grep/cli package (installed via npm)
|
||||
export function findSgCliPathSync(): string | null {
|
||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath && isValidBinary(cachedPath)) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
|
||||
const cliDir = dirname(cliPkgPath)
|
||||
const sgPath = join(cliDir, process.platform === "win32" ? "sg.exe" : "sg")
|
||||
const sgPath = join(cliDir, binaryName)
|
||||
|
||||
if (existsSync(sgPath)) {
|
||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
||||
return sgPath
|
||||
}
|
||||
} catch {
|
||||
// @ast-grep/cli not installed, try platform-specific package
|
||||
// @ast-grep/cli not installed
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, binaryName)
|
||||
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, astGrepName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
@@ -54,12 +68,39 @@ function findSgCliPath(): string {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to system PATH
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path) && isValidBinary(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
let resolvedCliPath: string | null = null
|
||||
|
||||
export function getSgCliPath(): string {
|
||||
if (resolvedCliPath !== null) {
|
||||
return resolvedCliPath
|
||||
}
|
||||
|
||||
const syncPath = findSgCliPathSync()
|
||||
if (syncPath) {
|
||||
resolvedCliPath = syncPath
|
||||
return syncPath
|
||||
}
|
||||
|
||||
return "sg"
|
||||
}
|
||||
|
||||
// ast-grep CLI path (auto-detected from node_modules or system PATH)
|
||||
export const SG_CLI_PATH = findSgCliPath()
|
||||
export function setSgCliPath(path: string): void {
|
||||
resolvedCliPath = path
|
||||
}
|
||||
|
||||
export const SG_CLI_PATH = getSgCliPath()
|
||||
|
||||
// CLI supported languages (25 total)
|
||||
export const CLI_LANGUAGES = [
|
||||
@@ -94,6 +135,10 @@ export const CLI_LANGUAGES = [
|
||||
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
|
||||
|
||||
// Language to file extensions mapping
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
|
||||
export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
bash: [".bash", ".sh", ".zsh", ".bats"],
|
||||
c: [".c", ".h"],
|
||||
@@ -121,3 +166,99 @@ export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
tsx: [".tsx"],
|
||||
yaml: [".yml", ".yaml"],
|
||||
}
|
||||
|
||||
export interface EnvironmentCheckResult {
|
||||
cli: {
|
||||
available: boolean
|
||||
path: string
|
||||
error?: string
|
||||
}
|
||||
napi: {
|
||||
available: boolean
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if ast-grep CLI and NAPI are available.
|
||||
* Call this at startup to provide early feedback about missing dependencies.
|
||||
*/
|
||||
export function checkEnvironment(): EnvironmentCheckResult {
|
||||
const result: EnvironmentCheckResult = {
|
||||
cli: {
|
||||
available: false,
|
||||
path: SG_CLI_PATH,
|
||||
},
|
||||
napi: {
|
||||
available: false,
|
||||
},
|
||||
}
|
||||
|
||||
// Check CLI availability
|
||||
if (existsSync(SG_CLI_PATH)) {
|
||||
result.cli.available = true
|
||||
} else if (SG_CLI_PATH === "sg") {
|
||||
// Fallback path - try which/where to find in PATH
|
||||
try {
|
||||
const { spawnSync } = require("child_process")
|
||||
const whichResult = spawnSync(process.platform === "win32" ? "where" : "which", ["sg"], {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
})
|
||||
result.cli.available = whichResult.status === 0 && !!whichResult.stdout?.trim()
|
||||
if (!result.cli.available) {
|
||||
result.cli.error = "sg binary not found in PATH"
|
||||
}
|
||||
} catch {
|
||||
result.cli.error = "Failed to check sg availability"
|
||||
}
|
||||
} else {
|
||||
result.cli.error = `Binary not found: ${SG_CLI_PATH}`
|
||||
}
|
||||
|
||||
// Check NAPI availability
|
||||
try {
|
||||
require("@ast-grep/napi")
|
||||
result.napi.available = true
|
||||
} catch (e) {
|
||||
result.napi.available = false
|
||||
result.napi.error = `@ast-grep/napi not installed: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Format environment check result as user-friendly message.
|
||||
*/
|
||||
export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
|
||||
const lines: string[] = ["ast-grep Environment Status:", ""]
|
||||
|
||||
// CLI status
|
||||
if (result.cli.available) {
|
||||
lines.push(`✓ CLI: Available (${result.cli.path})`)
|
||||
} else {
|
||||
lines.push(`✗ CLI: Not available`)
|
||||
if (result.cli.error) {
|
||||
lines.push(` Error: ${result.cli.error}`)
|
||||
}
|
||||
lines.push(` Install: bun add -D @ast-grep/cli`)
|
||||
}
|
||||
|
||||
// NAPI status
|
||||
if (result.napi.available) {
|
||||
lines.push(`✓ NAPI: Available`)
|
||||
} else {
|
||||
lines.push(`✗ NAPI: Not available`)
|
||||
if (result.napi.error) {
|
||||
lines.push(` Error: ${result.napi.error}`)
|
||||
}
|
||||
lines.push(` Install: bun add -D @ast-grep/napi`)
|
||||
}
|
||||
|
||||
lines.push("")
|
||||
lines.push(`CLI supports ${CLI_LANGUAGES.length} languages`)
|
||||
lines.push(`NAPI supports ${NAPI_LANGUAGES.length} languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
151
src/tools/ast-grep/downloader.ts
Normal file
151
src/tools/ast-grep/downloader.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { homedir } from "os"
|
||||
import { createRequire } from "module"
|
||||
|
||||
const REPO = "ast-grep/ast-grep"
|
||||
|
||||
// IMPORTANT: Update this when bumping @ast-grep/cli in package.json
|
||||
// This is only used as fallback when @ast-grep/cli package.json cannot be read
|
||||
const DEFAULT_VERSION = "0.40.0"
|
||||
|
||||
function getAstGrepVersion(): string {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkg = require("@ast-grep/cli/package.json")
|
||||
return pkg.version
|
||||
} catch {
|
||||
return DEFAULT_VERSION
|
||||
}
|
||||
}
|
||||
|
||||
interface PlatformInfo {
|
||||
arch: string
|
||||
os: string
|
||||
}
|
||||
|
||||
const PLATFORM_MAP: Record<string, PlatformInfo> = {
|
||||
"darwin-arm64": { arch: "aarch64", os: "apple-darwin" },
|
||||
"darwin-x64": { arch: "x86_64", os: "apple-darwin" },
|
||||
"linux-arm64": { arch: "aarch64", os: "unknown-linux-gnu" },
|
||||
"linux-x64": { arch: "x86_64", os: "unknown-linux-gnu" },
|
||||
"win32-x64": { arch: "x86_64", os: "pc-windows-msvc" },
|
||||
"win32-arm64": { arch: "aarch64", os: "pc-windows-msvc" },
|
||||
"win32-ia32": { arch: "i686", os: "pc-windows-msvc" },
|
||||
}
|
||||
|
||||
export function getCacheDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const localAppData = process.env.LOCALAPPDATA || process.env.APPDATA
|
||||
const base = localAppData || join(homedir(), "AppData", "Local")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
const xdgCache = process.env.XDG_CACHE_HOME
|
||||
const base = xdgCache || join(homedir(), ".cache")
|
||||
return join(base, "oh-my-opencode", "bin")
|
||||
}
|
||||
|
||||
export function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "sg.exe" : "sg"
|
||||
}
|
||||
|
||||
export function getCachedBinaryPath(): string | null {
|
||||
const binaryPath = join(getCacheDir(), getBinaryName())
|
||||
return existsSync(binaryPath) ? binaryPath : null
|
||||
}
|
||||
|
||||
async function extractZip(archivePath: string, destDir: string): Promise<void> {
|
||||
const proc =
|
||||
process.platform === "win32"
|
||||
? spawn(
|
||||
[
|
||||
"powershell",
|
||||
"-command",
|
||||
`Expand-Archive -Path '${archivePath}' -DestinationPath '${destDir}' -Force`,
|
||||
],
|
||||
{ stdout: "pipe", stderr: "pipe" }
|
||||
)
|
||||
: spawn(["unzip", "-o", archivePath, "-d", destDir], { stdout: "pipe", stderr: "pipe" })
|
||||
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode !== 0) {
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const toolHint =
|
||||
process.platform === "win32"
|
||||
? "Ensure PowerShell is available on your system."
|
||||
: "Please install 'unzip' (e.g., apt install unzip, brew install unzip)."
|
||||
throw new Error(`zip extraction failed (exit ${exitCode}): ${stderr}\n\n${toolHint}`)
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promise<string | null> {
|
||||
const platformKey = `${process.platform}-${process.arch}`
|
||||
const platformInfo = PLATFORM_MAP[platformKey]
|
||||
|
||||
if (!platformInfo) {
|
||||
console.error(`[oh-my-opencode] Unsupported platform for ast-grep: ${platformKey}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const cacheDir = getCacheDir()
|
||||
const binaryName = getBinaryName()
|
||||
const binaryPath = join(cacheDir, binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
|
||||
const { arch, os } = platformInfo
|
||||
const assetName = `app-${arch}-${os}.zip`
|
||||
const downloadUrl = `https://github.com/${REPO}/releases/download/${version}/${assetName}`
|
||||
|
||||
console.log(`[oh-my-opencode] Downloading ast-grep binary...`)
|
||||
|
||||
try {
|
||||
if (!existsSync(cacheDir)) {
|
||||
mkdirSync(cacheDir, { recursive: true })
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, { redirect: "follow" })
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const archivePath = join(cacheDir, assetName)
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
await Bun.write(archivePath, arrayBuffer)
|
||||
|
||||
await extractZip(archivePath, cacheDir)
|
||||
|
||||
if (existsSync(archivePath)) {
|
||||
unlinkSync(archivePath)
|
||||
}
|
||||
|
||||
if (process.platform !== "win32" && existsSync(binaryPath)) {
|
||||
chmodSync(binaryPath, 0o755)
|
||||
}
|
||||
|
||||
console.log(`[oh-my-opencode] ast-grep binary ready.`)
|
||||
|
||||
return binaryPath
|
||||
} catch (err) {
|
||||
console.error(
|
||||
`[oh-my-opencode] Failed to download ast-grep: ${err instanceof Error ? err.message : err}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureAstGrepBinary(): Promise<string | null> {
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
const version = getAstGrepVersion()
|
||||
return downloadAstGrep(version)
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
} from "./tools"
|
||||
import { ast_grep_search, ast_grep_replace } from "./tools"
|
||||
|
||||
export const builtinTools = {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
|
||||
export {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
}
|
||||
export { ast_grep_search, ast_grep_replace }
|
||||
export { ensureAstGrepBinary, getCachedBinaryPath, getCacheDir } from "./downloader"
|
||||
export { getAstGrepPath, isCliAvailable, ensureCliAvailable, startBackgroundInit } from "./cli"
|
||||
export { checkEnvironment, formatEnvironmentCheck } from "./constants"
|
||||
export type { EnvironmentCheckResult } from "./constants"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { parse, Lang } from "@ast-grep/napi"
|
||||
import { NAPI_LANGUAGES } from "./constants"
|
||||
import type { NapiLanguage, AnalyzeResult, MetaVariable, Range } from "./types"
|
||||
|
||||
const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
@@ -10,7 +11,16 @@ const LANG_MAP: Record<NapiLanguage, Lang> = {
|
||||
}
|
||||
|
||||
export function parseCode(code: string, lang: NapiLanguage) {
|
||||
return parse(LANG_MAP[lang], code)
|
||||
const parseLang = LANG_MAP[lang]
|
||||
if (!parseLang) {
|
||||
const supportedLangs = NAPI_LANGUAGES.join(", ")
|
||||
throw new Error(
|
||||
`Unsupported language for NAPI: "${lang}"\n` +
|
||||
`Supported languages: ${supportedLangs}\n\n` +
|
||||
`Use ast_grep_search for other languages (25 supported via CLI).`
|
||||
)
|
||||
}
|
||||
return parse(parseLang, code)
|
||||
}
|
||||
|
||||
export function findPattern(root: ReturnType<typeof parseCode>, pattern: string) {
|
||||
|
||||
@@ -1,22 +1,46 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { analyzeCode, transformCode, getRootInfo } from "./napi"
|
||||
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils"
|
||||
import type { CliLanguage, NapiLanguage } from "./types"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
function showOutputToUser(context: unknown, output: string): void {
|
||||
const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
|
||||
ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
const src = pattern.trim()
|
||||
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
}
|
||||
|
||||
if (["javascript", "typescript", "tsx"].includes(lang)) {
|
||||
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
|
||||
return `💡 Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const ast_grep_search = tool({
|
||||
description:
|
||||
"Search code patterns across filesystem using AST-aware matching. Supports 25 languages. " +
|
||||
"Use meta-variables: $VAR (single node), $$$ (multiple nodes). " +
|
||||
"IMPORTANT: Patterns must be complete AST nodes (valid code). " +
|
||||
"For functions, include params and body: 'export async function $NAME($$$) { $$$ }' not 'export async function $NAME'. " +
|
||||
"Examples: 'console.log($MSG)', 'def $FUNC($$$):', 'async function $NAME($$$)'",
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$)"),
|
||||
pattern: tool.schema.string().describe("AST pattern with meta-variables ($VAR, $$$). Must be complete AST node."),
|
||||
lang: tool.schema.enum(CLI_LANGUAGES).describe("Target language"),
|
||||
paths: tool.schema.array(tool.schema.string()).optional().describe("Paths to search (default: ['.'])"),
|
||||
globs: tool.schema.array(tool.schema.string()).optional().describe("Include/exclude globs (prefix ! to exclude)"),
|
||||
@@ -24,14 +48,23 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths,
|
||||
globs: args.globs,
|
||||
context: args.context,
|
||||
})
|
||||
const output = formatSearchResult(matches)
|
||||
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
}
|
||||
}
|
||||
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -57,7 +90,7 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
@@ -65,7 +98,7 @@ export const ast_grep_replace = tool({
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(matches, args.dryRun !== false)
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -76,83 +109,4 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_languages = tool({
|
||||
description:
|
||||
"List all supported languages for ast-grep tools with their file extensions. " +
|
||||
"Use this to determine valid language options.",
|
||||
args: {},
|
||||
execute: async (_args, context) => {
|
||||
const lines: string[] = [`Supported Languages (${CLI_LANGUAGES.length}):`]
|
||||
for (const lang of CLI_LANGUAGES) {
|
||||
const exts = LANG_EXTENSIONS[lang]?.join(", ") || ""
|
||||
lines.push(` ${lang}: ${exts}`)
|
||||
}
|
||||
lines.push("")
|
||||
lines.push(`NAPI (in-memory) languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
const output = lines.join("\n")
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_analyze = tool({
|
||||
description:
|
||||
"Parse code and extract AST structure with pattern matching (in-memory). " +
|
||||
"Extracts meta-variable bindings. Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Use for detailed code analysis without file I/O.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to analyze"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language (html, javascript, tsx, css, typescript)"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern to find (omit for root structure)"),
|
||||
extractMetaVars: tool.schema.boolean().optional().describe("Extract meta-variable bindings (default: true)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
if (!args.pattern) {
|
||||
const info = getRootInfo(args.code, args.lang as NapiLanguage)
|
||||
const output = `Root kind: ${info.kind}\nChildren: ${info.childCount}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
|
||||
const results = analyzeCode(args.code, args.lang as NapiLanguage, args.pattern, args.extractMetaVars !== false)
|
||||
const output = formatAnalyzeResult(results, args.extractMetaVars !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_transform = tool({
|
||||
description:
|
||||
"Transform code in-memory using AST-aware rewriting. " +
|
||||
"Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Returns transformed code without writing to filesystem.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to transform"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language"),
|
||||
pattern: tool.schema.string().describe("Pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement (can use $VAR from pattern)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const { transformed, editCount } = transformCode(
|
||||
args.code,
|
||||
args.lang as NapiLanguage,
|
||||
args.pattern,
|
||||
args.rewrite
|
||||
)
|
||||
const output = formatTransformResult(args.code, transformed, editCount)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,3 +51,11 @@ export interface TransformResult {
|
||||
transformed: string
|
||||
editCount: number
|
||||
}
|
||||
|
||||
export interface SgResult {
|
||||
matches: CliMatch[]
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import type { CliMatch, AnalyzeResult } from "./types"
|
||||
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(matches: CliMatch[]): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${matches.length} match(es):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.lines.trim()}`)
|
||||
@@ -17,15 +32,30 @@ export function formatSearchResult(matches: CliMatch[]): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found to replace"
|
||||
}
|
||||
|
||||
const prefix = isDryRun ? "[DRY RUN] " : ""
|
||||
const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.text}`)
|
||||
|
||||
129
src/tools/glob/cli.ts
Normal file
129
src/tools/glob/cli.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { spawn } from "bun"
|
||||
import {
|
||||
resolveGrepCli,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_LIMIT,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
RG_FILES_FLAGS,
|
||||
} from "./constants"
|
||||
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
||||
import { stat } from "node:fs/promises"
|
||||
|
||||
function buildRgArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = [
|
||||
...RG_FILES_FLAGS,
|
||||
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||
]
|
||||
|
||||
if (options.hidden) args.push("--hidden")
|
||||
if (options.noIgnore) args.push("--no-ignore")
|
||||
|
||||
args.push(`--glob=${options.pattern}`)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildFindArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = ["."]
|
||||
|
||||
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
|
||||
args.push("-maxdepth", String(maxDepth))
|
||||
|
||||
args.push("-type", "f")
|
||||
args.push("-name", options.pattern)
|
||||
|
||||
if (!options.hidden) {
|
||||
args.push("-not", "-path", "*/.*")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function getFileMtime(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await stat(filePath)
|
||||
return stats.mtime.getTime()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
|
||||
const cli = resolveGrepCli()
|
||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
|
||||
|
||||
const isRg = cli.backend === "rg"
|
||||
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
|
||||
|
||||
const paths = options.paths?.length ? options.paths : ["."]
|
||||
if (isRg) {
|
||||
args.push(...paths)
|
||||
}
|
||||
|
||||
const cwd = paths[0] || "."
|
||||
|
||||
const proc = spawn([cli.path, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: isRg ? undefined : cwd,
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Glob search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
try {
|
||||
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode > 1 && stderr.trim()) {
|
||||
return {
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
truncated: false,
|
||||
error: stderr.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
const lines = outputToProcess.trim().split("\n").filter(Boolean)
|
||||
|
||||
const files: FileMatch[] = []
|
||||
let truncated = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
const filePath = isRg ? line : `${cwd}/${line}`
|
||||
const mtime = await getFileMtime(filePath)
|
||||
files.push({ path: filePath, mtime })
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
return {
|
||||
files,
|
||||
totalFiles: files.length,
|
||||
truncated: truncated || truncatedOutput,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
truncated: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/tools/glob/constants.ts
Normal file
12
src/tools/glob/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { resolveGrepCli, type GrepBackend } from "../grep/constants"
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_LIMIT = 100
|
||||
export const DEFAULT_MAX_DEPTH = 20
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const RG_FILES_FLAGS = [
|
||||
"--files",
|
||||
"--color=never",
|
||||
"--glob=!.git/*",
|
||||
] as const
|
||||
3
src/tools/glob/index.ts
Normal file
3
src/tools/glob/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { glob } from "./tools"
|
||||
|
||||
export { glob }
|
||||
36
src/tools/glob/tools.ts
Normal file
36
src/tools/glob/tools.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { runRgFiles } from "./cli"
|
||||
import { formatGlobResult } from "./utils"
|
||||
|
||||
export const glob = tool({
|
||||
description:
|
||||
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
|
||||
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
|
||||
"Returns matching file paths sorted by modification time. " +
|
||||
"Use this tool when you need to find files by name patterns.",
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("The glob pattern to match files against"),
|
||||
path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The directory to search in. If not specified, the current working directory will be used. " +
|
||||
"IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " +
|
||||
"simply omit it for the default behavior. Must be a valid directory path if provided."
|
||||
),
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const paths = args.path ? [args.path] : undefined
|
||||
|
||||
const result = await runRgFiles({
|
||||
pattern: args.pattern,
|
||||
paths,
|
||||
})
|
||||
|
||||
return formatGlobResult(result)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
21
src/tools/glob/types.ts
Normal file
21
src/tools/glob/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface FileMatch {
|
||||
path: string
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface GlobResult {
|
||||
files: FileMatch[]
|
||||
totalFiles: number
|
||||
truncated: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GlobOptions {
|
||||
pattern: string
|
||||
paths?: string[]
|
||||
hidden?: boolean
|
||||
noIgnore?: boolean
|
||||
maxDepth?: number
|
||||
timeout?: number
|
||||
limit?: number
|
||||
}
|
||||
26
src/tools/glob/utils.ts
Normal file
26
src/tools/glob/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { GlobResult } from "./types"
|
||||
|
||||
export function formatGlobResult(result: GlobResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.files.length === 0) {
|
||||
return "No files found"
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Found ${result.totalFiles} file(s)`)
|
||||
lines.push("")
|
||||
|
||||
for (const file of result.files) {
|
||||
lines.push(file.path)
|
||||
}
|
||||
|
||||
if (result.truncated) {
|
||||
lines.push("")
|
||||
lines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user