Compare commits

..

35 Commits

Author SHA1 Message Date
github-actions[bot]
feb87cf716 release: v0.1.20 2025-12-05 12:58:56 +00:00
github-actions[bot]
181194ae3c release: v0.1.19 2025-12-05 12:00:31 +00:00
YeonGyu-Kim
b8f5599e61 feat(ast-grep): add helpful hints for incomplete Python patterns
- Show hints when Python class/function patterns return empty results
- Detect patterns ending with ':' that need body (class :, def ():)
- Removed validation that could cause false positives
- Hints only appear on empty results, not on successful matches
2025-12-05 20:59:05 +09:00
github-actions[bot]
ea2b09ebb9 release: v0.1.18 2025-12-05 11:07:21 +00:00
YeonGyu-Kim
143dd8aaa9 fix(session-recovery): improve error detection and add continue prompt
- Enhance error type detection for thinking block order issues
- Add continue prompt after successful session recovery
- Improve error message matching logic
2025-12-05 20:01:47 +09:00
YeonGyu-Kim
36169c83fb feat(ast-grep): add CLI path resolution and auto-download functionality
- Add automatic CLI binary path detection and resolution
- Implement lazy binary download with caching
- Add environment check utilities for CLI and NAPI availability
- Improve error handling and fallback mechanisms
- Export new utilities from index.ts
2025-12-05 20:01:35 +09:00
github-actions[bot]
bf9f033635 release: v0.1.17 2025-12-05 07:26:35 +00:00
YeonGyu-Kim
de2f9dfc86 chore: move schema to assets/, add dist/ to gitignore 2025-12-05 16:24:54 +09:00
YeonGyu-Kim
5f5218ccae docs: fix Agents config section - remove incorrect OpenCode link, add correct options 2025-12-05 16:17:00 +09:00
YeonGyu-Kim
41d3817910 docs: reorganize Configuration section to bottom, add LSP config docs 2025-12-05 16:13:25 +09:00
YeonGyu-Kim
a119429ebe docs: remove MCP config from Agent section (already in top-level Config) 2025-12-05 16:01:19 +09:00
github-actions[bot]
9bed2dd79a release: v0.1.16 2025-12-05 06:55:27 +00:00
YeonGyu-Kim
4c1d534093 docs: simplify agent configuration, add OpenCode doc links 2025-12-05 15:52:04 +09:00
github-actions[bot]
a04234eaab release: v0.1.15 2025-12-05 06:33:29 +00:00
YeonGyu-Kim
6d0e4c49c2 docs: add deployment workflow documentation to AGENTS.md 2025-12-05 15:32:09 +09:00
YeonGyu-Kim
8f39575264 fix(session-recovery): handle empty content from interrupted reasoning (#6)
* fix(publish): make git operations idempotent

- Check for staged changes before commit
- Check if tag exists before creating
- Check if release exists before creating

* fix(session-recovery): handle empty content from interrupted reasoning

- Add THINKING_TYPES set including 'reasoning' type (OpenCode's thinking)
- Add hasNonEmptyOutput() to detect messages with only thinking/meta parts
- Add findEmptyContentMessage() to scan all messages for empty content
- Handle step-start/step-finish meta parts in empty content detection
- Patch interrupted messages with '(interrupted)' text before falling back to revert
2025-12-05 15:28:22 +09:00
YeonGyu-Kim
2464473731 fix(ast-grep): add validation for incomplete function declaration patterns (#5)
* fix(publish): make git operations idempotent

- Check for staged changes before commit
- Check if tag exists before creating
- Check if release exists before creating

* fix(ast-grep): add validation for incomplete function declaration patterns

- Add validatePatternForCli function to detect incomplete patterns like
  'export async function $METHOD' (missing params and body)
- Only validates JS/TS languages (javascript, typescript, tsx)
- Provides helpful error message with correct pattern examples
- Update tool description to clarify complete AST nodes required

This fixes the issue where incomplete patterns would fail silently
with no results instead of providing actionable feedback.
2025-12-05 15:17:42 +09:00
YeonGyu-Kim
1b0a8adb2b refactor(comment-checker): remove WASM fallback, use CLI-only with lazy download
- Remove tree-sitter-wasms and web-tree-sitter dependencies
- Delete detector.ts (320 lines of WASM implementation)
- Add downloader.ts for lazy binary download from GitHub Releases
- Simplify index.ts to CLI-only mode
- Cache binary at ~/.cache/oh-my-opencode/bin/
- Fall back to 'Comment checking disabled' when binary unavailable
2025-12-05 14:51:47 +09:00
YeonGyu-Kim
4b7a4b04d0 docs: restructure README for better readability and UX (#4)
- Move TL;DR section before Installation for quick overview
- Wrap 'For LLM Agents' section in collapsible details tag
- Simplify Configuration section (remove verbose schema explanations)
- Move model override example to Features > Agents section
- Add context7 MCP to Korean README
2025-12-05 14:43:52 +09:00
YeonGyu-Kim
f6f2fad1fc fix: correct OpenCode docs link (404 -> valid URL) (#3)
- Change https://opencode.ai/docs/installation/ to https://opencode.ai/docs
- The /installation/ path returns 404, main docs page has Install section
2025-12-05 14:28:44 +09:00
YeonGyu-Kim
3ca9693de0 Merge pull request #2 from code-yeongyu/docs-schema-and-readme-config
docs: add comprehensive configuration documentation
2025-12-05 14:28:32 +09:00
YeonGyu-Kim
e2078763a6 fix: resolve merge conflict with master (include both config and LLM agent guide) 2025-12-05 14:28:11 +09:00
YeonGyu-Kim
5121d0eed2 docs: add comprehensive configuration documentation and include schema.json
- Add dist/oh-my-opencode.schema.json to repository (fix gitignore pattern)
- Document all configuration options in README.md (English)
- Add Configuration section to README.ko.md (Korean) - was completely missing
- Include agent configuration examples (Anthropic-only setup)
- Add MCP/Agent disable instructions
- Document permission options for agents
2025-12-05 14:24:12 +09:00
YeonGyu-Kim
973caf9b7d docs: add 'For LLM Agents' setup guide section (#1)
- Add step-by-step guide for LLM agents to setup oh-my-opencode
- Include OpenCode installation check with link to official docs
- Include oh-my-opencode plugin configuration script
- Update Table of Contents for both EN/KO README files
2025-12-05 14:17:48 +09:00
YeonGyu-Kim
b277d96de8 fix: remove Windows ARM64 support 2025-12-05 13:47:21 +09:00
YeonGyu-Kim
2c223d96a1 feat(comment-checker): add Windows support 2025-12-05 13:44:52 +09:00
YeonGyu-Kim
695aeafb86 fix(comment-checker): change npm scope to @code-yeongyu 2025-12-05 11:40:16 +09:00
YeonGyu-Kim
812c544bfa feat(comment-checker): add native CLI support with WASM fallback
- Add cli.ts for native binary resolution and spawning
- Update index.ts to use CLI when available, WASM as fallback
- Add Edit/MultiEdit support to types.ts for proper CLI input
2025-12-05 11:31:52 +09:00
github-actions[bot]
edf0e7d946 release: v0.1.12 2025-12-05 02:10:36 +00:00
YeonGyu-Kim
baa7fadab1 fix(comment-checker): use runtime wasm path resolution instead of require.resolve
require.resolve() was evaluated at build time, hardcoding CI paths.
Now uses import.meta.resolve() at runtime to find wasm files.
2025-12-05 11:09:41 +09:00
github-actions[bot]
a06bbeb9ee release: v0.1.11 2025-12-05 02:05:44 +00:00
YeonGyu-Kim
f3a92db203 chore: bump version to 0.1.12 2025-12-05 11:02:43 +09:00
YeonGyu-Kim
fd6e230889 perf(comment-checker): add LSP-style background language warming
- Warmup common languages (python, typescript, javascript, tsx, go, rust, java) on plugin init
- Non-blocking background initialization using Promise.then() pattern
- First parse call uses pre-cached language - zero user wait time
- Refactor parser manager with ManagedLanguage interface for better state tracking
2025-12-05 11:02:35 +09:00
YeonGyu-Kim
50ea492065 chore: bump version to 0.1.11 2025-12-05 10:56:21 +09:00
YeonGyu-Kim
f5f2053b7a fix(comment-checker): fix error skip bug and add parser/language caching
- Fix overly broad error detection that skipped comments when LSP warnings present
- Add Parser class and language WASM caching for ~3.5x faster subsequent parses
- Add debug logging controlled by COMMENT_CHECKER_DEBUG=1 env var
2025-12-05 10:56:08 +09:00
24 changed files with 1807 additions and 417 deletions

View File

@@ -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

View File

@@ -3,9 +3,10 @@
## 목차
- [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)
@@ -15,6 +16,7 @@
- [Safe Grep](#safe-grep)
- [내장 MCPs](#내장-mcps)
- [기타 편의 기능](#기타-편의-기능)
- [설정](#설정)
- [작성자의 노트](#작성자의-노트)
- [주의](#주의)
@@ -33,6 +35,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 +58,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
@@ -93,6 +146,8 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
### Tools
#### 내장 LSP Tools
@@ -124,10 +179,98 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
#### 내장 MCPs
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
- **context7**: 라이브러리 문서 조회. 정확한 코딩을 위해 최신 라이브러리 문서를 가져옵니다.
필요 없다면 `oh-my-opencode.json`에서 비활성화할 수 있습니다:
```json
{
"disabled_mcps": ["websearch_exa"]
}
```
### 기타 편의 기능
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
## 설정
설정 파일 위치 (우선순위 순):
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`.
## 작성자의 노트
Oh My OpenCode 를 설치하세요. 복잡하게 OpenCode 구성을 만들지마세요.
@@ -153,5 +296,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) 이 변경사항이 포함된 최신 버전을 사용해주세요.

177
README.md
View File

@@ -3,11 +3,10 @@ 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)
- [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)
@@ -17,6 +16,7 @@ English | [한국어](README.ko.md)
- [Safe Grep](#safe-grep)
- [Built-in MCPs](#built-in-mcps)
- [Other Features](#other-features)
- [Configuration](#configuration)
- [Author's Note](#authors-note)
- [Warnings](#warnings)
@@ -33,6 +33,17 @@ You don't write code just for a paycheck? You write because you genuinely love i
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`:
@@ -45,49 +56,56 @@ Add to `~/.config/opencode/opencode.json`:
}
```
## Configuration
## For LLM Agents
You can configure Oh My OpenCode by creating a `oh-my-opencode.json` (or `.oh-my-opencode.json`) file in your project root.
<details>
<summary>For LLM Agents (Click to expand)</summary>
### JSON Schema Support
If you are an LLM Agent helping a user set up oh-my-opencode, follow these steps:
For autocompletion and validation in VS Code (or other editors), add the `$schema` property to your configuration file.
### Step 1: Verify OpenCode Installation
**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"]
}
```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
```
**Using Local Schema:**
```json
{
"$schema": "./node_modules/oh-my-opencode/dist/oh-my-opencode.schema.json"
}
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
```
### Disable specific MCPs
### Step 3: Verify Setup
If you want to disable specific built-in MCPs, you can use the `disabled_mcps` option.
```json
{
"disabled_mcps": ["context7", "websearch_exa"]
}
```bash
opencode --version # Should be >= 1.0.132
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
```
## TL;DR
> **Note**: If model authentication is needed, run `opencode` and use `/connect` command, or assist the user with `opencode auth login` via tmux.
- **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.
</details>
## Why OpenCode & Why Oh My OpenCode
@@ -124,6 +142,8 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
- **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.
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
### Tools
#### Built-in LSP Tools
@@ -159,10 +179,97 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
- **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"]
}
```
### Other Features
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
## 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.

View 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"
]
}
}
}
}
}
}
}
}

View File

@@ -7,9 +7,8 @@
"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",
"tree-sitter-wasms": "^0.1.12",
"web-tree-sitter": "^0.24.7",
"zod": "^4.1.8",
},
"devDependencies": {
@@ -23,6 +22,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 +62,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.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
"@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 +98,10 @@
"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=="],
"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=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
}
}

View File

@@ -0,0 +1,162 @@
# Comment-Checker TypeScript Port 구현 계획
## 1. 아키텍처 개요
### 1.1 핵심 도전 과제
**OpenCode Hook의 제약사항:**
- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능
- `tool.execute.after`: `tool_input`**제공되지 않음** (Claude Code와의 핵심 차이점)
- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회
### 1.2 디렉토리 구조
```
src/hooks/comment-checker/
├── index.ts # Hook factory, 메인 엔트리포인트
├── types.ts # 모든 타입 정의
├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록
├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지
├── filters/
│ ├── index.ts # 필터 barrel export
│ ├── bdd.ts # BDD 패턴 필터
│ ├── directive.ts # 린터/타입체커 디렉티브 필터
│ ├── docstring.ts # 독스트링 필터
│ └── shebang.ts # Shebang 필터
├── output/
│ ├── index.ts # 출력 barrel export
│ ├── formatter.ts # FormatHookMessage
│ └── xml-builder.ts # BuildCommentsXML
└── utils.ts # 유틸리티 함수
```
### 1.3 데이터 흐름
```
[write/edit 도구 실행]
┌──────────────────────┐
│ tool.execute.before │
│ - 파일 경로 캡처 │
│ - pendingCalls Map │
│ 에 저장 │
└──────────┬───────────┘
[도구 실제 실행]
┌──────────────────────┐
│ tool.execute.after │
│ - pendingCalls에서 │
│ 데이터 조회 │
│ - 파일 읽기 │
│ - 코멘트 감지 │
│ - 필터 적용 │
│ - 메시지 주입 │
└──────────────────────┘
```
---
## 2. 구현 순서
### Phase 1: 기반 구조
1. `src/hooks/comment-checker/` 디렉토리 생성
2. `types.ts` - 모든 타입 정의
3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴
### Phase 2: 필터 구현
4. `filters/bdd.ts` - BDD 패턴 필터
5. `filters/directive.ts` - 디렉티브 필터
6. `filters/docstring.ts` - 독스트링 필터
7. `filters/shebang.ts` - Shebang 필터
8. `filters/index.ts` - 필터 조합
### Phase 3: 코어 로직
9. `detector.ts` - web-tree-sitter 기반 코멘트 감지
10. `output/xml-builder.ts` - XML 출력
11. `output/formatter.ts` - 메시지 포매팅
### Phase 4: Hook 통합
12. `index.ts` - Hook factory 및 상태 관리
13. `src/hooks/index.ts` 업데이트 - export 추가
### Phase 5: 의존성 및 빌드
14. `package.json` 업데이트 - web-tree-sitter 추가
15. typecheck 및 build 검증
---
## 3. 핵심 구현 사항
### 3.1 언어 레지스트리 (38개 언어)
```typescript
const LANGUAGE_REGISTRY: Record<string, LanguageConfig> = {
python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." },
javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" },
typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" },
tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" },
go: { extensions: [".go"], commentQuery: "(comment) @comment" },
rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" },
// ... 38개 전체
}
```
### 3.2 필터 로직
**BDD 필터**: `given, when, then, arrange, act, assert`
**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+
**Docstring 필터**: `IsDocstring || starts with /**`
**Shebang 필터**: `starts with #!`
### 3.3 출력 형식 (Go 버전과 100% 동일)
```
COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
Your recent changes contain comments or docstrings, which triggered this hook.
You need to take immediate action. You must follow the conditions below.
(Listed in priority order - you must always act according to this priority order)
CRITICAL WARNING: This hook message MUST NEVER be ignored...
<comments file="/path/to/file.py">
<comment line-number="10">// comment text</comment>
</comments>
```
---
## 4. 생성할 파일 목록
1. `src/hooks/comment-checker/types.ts`
2. `src/hooks/comment-checker/constants.ts`
3. `src/hooks/comment-checker/filters/bdd.ts`
4. `src/hooks/comment-checker/filters/directive.ts`
5. `src/hooks/comment-checker/filters/docstring.ts`
6. `src/hooks/comment-checker/filters/shebang.ts`
7. `src/hooks/comment-checker/filters/index.ts`
8. `src/hooks/comment-checker/output/xml-builder.ts`
9. `src/hooks/comment-checker/output/formatter.ts`
10. `src/hooks/comment-checker/output/index.ts`
11. `src/hooks/comment-checker/detector.ts`
12. `src/hooks/comment-checker/index.ts`
## 5. 수정할 파일 목록
1. `src/hooks/index.ts` - export 추가
2. `package.json` - web-tree-sitter 의존성
---
## 6. Definition of Done
- [ ] write/edit 도구 실행 시 코멘트 감지 동작
- [ ] 4개 필터 모두 정상 작동
- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go)
- [ ] Go 버전과 동일한 출력 형식
- [ ] typecheck 통과
- [ ] build 성공

View File

@@ -0,0 +1,12 @@
#!/bin/bash
set -e
cd /Users/yeongyu/local-workspaces/oh-my-opencode
echo "=== Pushing to origin ==="
git push -f origin master
echo "=== Triggering workflow ==="
gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1
echo "=== Done! ==="
echo "Usage: ./local-ignore/push-and-release.sh 0.1.6"

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "0.1.10",
"version": "0.1.20",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -44,9 +44,8 @@
"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",
"tree-sitter-wasms": "^0.1.12",
"web-tree-sitter": "^0.24.7",
"zod": "^4.1.8"
},
"devDependencies": {
@@ -58,6 +57,7 @@
},
"trustedDependencies": [
"@ast-grep/cli",
"@ast-grep/napi"
"@ast-grep/napi",
"@code-yeongyu/comment-checker"
]
}

View File

@@ -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,

View File

@@ -78,13 +78,35 @@ 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> {

View File

@@ -0,0 +1,267 @@
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)
}
}
type Platform = "darwin" | "linux" | "win32" | "unsupported"
function getPlatformPackageName(): string | null {
const platform = process.platform as Platform
const arch = process.arch
const platformMap: Record<string, string> = {
"darwin-arm64": "@code-yeongyu/comment-checker-darwin-arm64",
"darwin-x64": "@code-yeongyu/comment-checker-darwin-x64",
"linux-arm64": "@code-yeongyu/comment-checker-linux-arm64",
"linux-x64": "@code-yeongyu/comment-checker-linux-x64",
"win32-x64": "@code-yeongyu/comment-checker-windows-x64",
}
return platformMap[`${platform}-${arch}`] ?? null
}
function getBinaryName(): string {
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
}
/**
* Synchronously find comment-checker binary path.
* Checks installed packages, homebrew, cache, and system PATH.
* Does NOT trigger download.
*/
function findCommentCheckerPathSync(): string | null {
const binaryName = getBinaryName()
// 1. Try to find from @code-yeongyu/comment-checker package
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")
}
// 2. Try platform-specific package directly (legacy, for backwards compatibility)
const platformPkg = getPlatformPackageName()
if (platformPkg) {
try {
const require = createRequire(import.meta.url)
const pkgPath = require.resolve(`${platformPkg}/package.json`)
const pkgDir = dirname(pkgPath)
const binaryPath = join(pkgDir, "bin", binaryName)
if (existsSync(binaryPath)) {
debugLog("found binary in platform package:", binaryPath)
return binaryPath
}
} catch {
debugLog("platform package not installed:", platformPkg)
}
}
// 3. Try homebrew installation (macOS)
if (process.platform === "darwin") {
const homebrewPaths = [
"/opt/homebrew/bin/comment-checker",
"/usr/local/bin/comment-checker",
]
for (const path of homebrewPaths) {
if (existsSync(path)) {
debugLog("found binary via homebrew:", path)
return path
}
}
}
// 4. Try cached binary (lazy download location)
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
debugLog("found binary in cache:", cachedPath)
return cachedPath
}
// 5. Try system PATH (as fallback)
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)
}

View File

@@ -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",
@@ -191,14 +77,3 @@ Review in the above priority order and take the corresponding action EVERY TIME
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
}

View File

@@ -1,146 +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
const treeSitterWasmPath = require.resolve("web-tree-sitter/tree-sitter.wasm")
await Parser.init({
locateFile: () => treeSitterWasmPath,
})
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 []
}
}

View 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()
}

View File

@@ -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 ?? 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")
}
}

View File

@@ -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

View File

@@ -70,7 +70,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"
}
@@ -211,29 +214,93 @@ async function recoverThinkingDisabledViolation(
return false
}
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
function hasNonEmptyOutput(msg: MessageData): boolean {
const parts = msg.parts
if (!parts || parts.length === 0) return false
return parts.some((p) => {
if (THINKING_TYPES.has(p.type)) return false
if (p.type === "step-start" || p.type === "step-finish") return false
if (p.type === "text" && p.text && p.text.trim()) return true
if (p.type === "tool_use" && p.id) return true
if (p.type === "tool_result") return true
return false
})
}
function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
for (let i = 0; i < msgs.length; i++) {
const msg = msgs[i]
const isLastMessage = i === msgs.length - 1
const isAssistant = msg.info?.role === "assistant"
if (isLastMessage && isAssistant) continue
if (!hasNonEmptyOutput(msg)) {
return msg
}
}
return null
}
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 {
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
const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg
const messageID = emptyMsg.info?.id
if (!messageID) return false
const existingParts = emptyMsg.parts || []
const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every(
(p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish"
)
if (hasOnlyThinkingOrMeta) {
const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }]
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
}
}
const revertTargetID = emptyMsg.info?.parentID || messageID
await client.session.revert({
path: { id: sessionID },
body: { messageID: revertTargetID },
query: { directory },
})
return true
} catch {
return false

View File

@@ -136,7 +136,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
sessionID,
error,
}
await sessionRecovery.handleSessionRecovery(messageInfo)
const recovered = await sessionRecovery.handleSessionRecovery(messageInfo)
if (recovered && sessionID && sessionID === mainSessionID) {
await ctx.client.session.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: "continue" }] },
query: { directory: ctx.directory },
}).catch(() => {})
}
}
if (sessionID && sessionID === mainSessionID) {

View File

@@ -1,5 +1,7 @@
import { spawn } from "bun"
import { SG_CLI_PATH } from "./constants"
import { existsSync } from "fs"
import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants"
import { ensureAstGrepBinary } from "./downloader"
import type { CliMatch, CliLanguage } from "./types"
export interface RunOptions {
@@ -12,6 +14,65 @@ export interface RunOptions {
updateAll?: boolean
}
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(() => {})
}
}
interface SpawnResult {
stdout: string
stderr: string
exitCode: number
}
async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> {
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
return { stdout, stderr, exitCode }
}
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
@@ -35,14 +96,45 @@ 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], {
stdout: "pipe",
stderr: "pipe",
})
let cliPath = getSgCliPath()
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const exitCode = await proc.exited
if (!existsSync(cliPath) && cliPath !== "sg") {
const downloadedPath = await getAstGrepPath()
if (downloadedPath) {
cliPath = downloadedPath
}
}
let result: SpawnResult
try {
result = await spawnSg(cliPath, args)
} catch (e) {
const error = e as NodeJS.ErrnoException
if (
error.code === "ENOENT" ||
error.message?.includes("ENOENT") ||
error.message?.includes("not found")
) {
const downloadedPath = await ensureAstGrepBinary()
if (downloadedPath) {
resolvedCliPath = downloadedPath
setSgCliPath(downloadedPath)
result = await spawnSg(downloadedPath, args)
} else {
throw new 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`
)
}
} else {
throw new Error(`Failed to spawn ast-grep: ${error.message}`)
}
}
const { stdout, stderr, exitCode } = result
if (exitCode !== 0 && stdout.trim() === "") {
if (stderr.includes("No files found")) {
@@ -64,3 +156,13 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
return []
}
}
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)
}

View File

@@ -1,6 +1,7 @@
import { createRequire } from "module"
import { dirname, join } from "path"
import { existsSync } from "fs"
import { getCachedBinaryPath } from "./downloader"
type Platform = "darwin" | "linux" | "win32" | "unsupported"
@@ -21,30 +22,30 @@ 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"
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)) {
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)) {
return binaryPath
@@ -54,12 +55,44 @@ 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)) {
return path
}
}
}
const cachedPath = getCachedBinaryPath()
if (cachedPath) {
return cachedPath
}
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 = [
@@ -121,3 +154,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")
}

View 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)
}

View File

@@ -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"

View File

@@ -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) {

View File

@@ -10,13 +10,36 @@ function showOutputToUser(context: unknown, 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(":")) {
return `💡 Hint: Python class patterns need body. Try "class $NAME" or include body with $$$BODY`
}
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
return `💡 Hint: Python function patterns need body. Try "def $FUNC($$$):\\n $$$BODY"`
}
}
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)"),
@@ -31,7 +54,16 @@ export const ast_grep_search = tool({
globs: args.globs,
context: args.context,
})
const output = formatSearchResult(matches)
let output = formatSearchResult(matches)
if (matches.length === 0) {
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
if (hint) {
output += `\n\n${hint}`
}
}
showOutputToUser(context, output)
return output
} catch (e) {