Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04234eaab | ||
|
|
6d0e4c49c2 | ||
|
|
8f39575264 | ||
|
|
2464473731 | ||
|
|
1b0a8adb2b | ||
|
|
4b7a4b04d0 | ||
|
|
f6f2fad1fc | ||
|
|
3ca9693de0 | ||
|
|
e2078763a6 | ||
|
|
5121d0eed2 | ||
|
|
973caf9b7d | ||
|
|
b277d96de8 | ||
|
|
2c223d96a1 | ||
|
|
695aeafb86 | ||
|
|
812c544bfa | ||
|
|
edf0e7d946 | ||
|
|
baa7fadab1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,7 +2,8 @@
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist/*
|
||||
!dist/oh-my-opencode.schema.json
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
|
||||
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
|
||||
|
||||
204
README.ko.md
204
README.ko.md
@@ -3,9 +3,14 @@
|
||||
## 목차
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [설치](#설치)
|
||||
- [세 줄 요약](#세-줄-요약)
|
||||
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [설치](#설치)
|
||||
- [설정](#설정)
|
||||
- [특정 MCP 비활성화](#특정-mcp-비활성화)
|
||||
- [특정 Agent 비활성화](#특정-agent-비활성화)
|
||||
- [Agent 설정](#agent-설정)
|
||||
- [LLM Agent를 위한 안내](#llm-agent를-위한-안내)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [기능](#기능)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
@@ -33,6 +38,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 +61,162 @@ 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 을 실행하여 사용자의 구성을 도와주세요.
|
||||
`oh-my-opencode.json` (또는 `.oh-my-opencode.json`) 파일을 프로젝트 루트에 생성해서 Oh My OpenCode를 입맛대로 설정할 수 있어.
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
설정 파일은 스키마를 통해 자동완성을 지원합니다. 자세한 내용은 각 기능 섹션에서 설명합니다.
|
||||
|
||||
### 특정 MCP 비활성화
|
||||
|
||||
특정 MCP가 거슬린다면 끌 수 있어.
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["websearch_exa"]
|
||||
}
|
||||
```
|
||||
|
||||
### 특정 Agent 비활성화
|
||||
|
||||
특정 에이전트가 마음에 안 들거나, 토큰을 아끼고 싶다면 비활성화해.
|
||||
비활성화 가능한 목록: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_agents": ["frontend-ui-ux-engineer"]
|
||||
}
|
||||
```
|
||||
|
||||
### Agent 설정
|
||||
|
||||
각 에이전트의 모델, 프롬프트, 권한 등을 세밀하게 조정할 수 있어.
|
||||
|
||||
**설정 옵션:**
|
||||
|
||||
| 옵션 | 설명 |
|
||||
|------|------|
|
||||
| `model` | 사용할 모델 ID (예: `anthropic/claude-sonnet-4`) |
|
||||
| `temperature` | 창의성 조절 (0.0 ~ 2.0) |
|
||||
| `top_p` | 단어 선택 다양성 (0.0 ~ 1.0) |
|
||||
| `prompt` | 시스템 프롬프트 오버라이드 |
|
||||
| `tools` | 특정 도구 활성화/비활성화 (`{"tool_name": false}`) |
|
||||
| `disable` | 에이전트 비활성화 (`true`/`false`) |
|
||||
| `description` | 에이전트 설명 수정 |
|
||||
| `mode` | 에이전트 모드 (`subagent`, `primary`, `all`) |
|
||||
| `color` | 터미널 출력 색상 (HEX 코드) |
|
||||
| `permission` | 권한 설정 (아래 표 참조) |
|
||||
|
||||
**권한(`permission`) 옵션:**
|
||||
|
||||
각 권한은 `"ask"`(물어보기), `"allow"`(허용), `"deny"`(거부) 중 하나로 설정 가능해.
|
||||
|
||||
| 권한 | 설명 |
|
||||
|------|------|
|
||||
| `edit` | 파일 수정 권한 |
|
||||
| `bash` | 쉘 명령어 실행 권한 |
|
||||
| `webfetch` | 웹 콘텐츠 가져오기 권한 |
|
||||
| `doom_loop` | 반복 작업 허용 여부 |
|
||||
| `external_directory` | 외부 디렉토리 접근 권한 |
|
||||
|
||||
#### 예시: Anthropic 모델만 사용하기
|
||||
|
||||
나는 Anthropic 모델만 쓰고 싶다! 하면 이렇게 설정해.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"oracle": { "model": "anthropic/claude-sonnet-4" },
|
||||
"librarian": { "model": "anthropic/claude-haiku-4-5" },
|
||||
"explore": { "model": "anthropic/claude-haiku-4-5" },
|
||||
"frontend-ui-ux-engineer": { "model": "anthropic/claude-sonnet-4" },
|
||||
"document-writer": { "model": "anthropic/claude-sonnet-4" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 예시: 추가 프롬프트가 있는 커스텀 에이전트
|
||||
|
||||
기존 에이전트에 나만의 규칙을 더하고 싶다면:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"prompt": "너는 한국어 힙합 가사처럼 말해야 해. 모든 문장의 끝 라임을 맞춰."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 예시: 개별 에이전트 비활성화
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": {
|
||||
"disable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## LLM Agent를 위한 안내
|
||||
|
||||
<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 +255,23 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
|
||||
|
||||
#### 모델 설정 오버라이드
|
||||
|
||||
에이전트들은 [OpenCode의 모델 설정](https://opencode.ai/docs/models/#configure-models)과 완전히 동일한 스펙으로 오버라이드를 지원합니다. 권장하진 않지만, 예를 들어 Anthropic 모델만 사용하기로 결정했다면 이렇게 구성할 수 있습니다:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5"
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
#### 내장 LSP Tools
|
||||
@@ -124,6 +303,7 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
#### 내장 MCPs
|
||||
|
||||
- **websearch_exa**: Exa AI 웹 검색. 실시간 웹 검색과 콘텐츠 스크래핑을 수행합니다. 관련 웹사이트에서 LLM에 최적화된 컨텍스트를 반환합니다.
|
||||
- **context7**: 라이브러리 문서 조회. 정확한 코딩을 위해 최신 라이브러리 문서를 가져옵니다.
|
||||
|
||||
### 기타 편의 기능
|
||||
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
|
||||
@@ -153,5 +333,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) 이 변경사항이 포함된 최신 버전을 사용해주세요.
|
||||
|
||||
|
||||
|
||||
206
README.md
206
README.md
@@ -3,11 +3,14 @@ English | [한국어](README.ko.md)
|
||||
## Contents
|
||||
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [TL;DR](#tldr)
|
||||
- [Installation](#installation)
|
||||
- [For LLM Agents](#for-llm-agents)
|
||||
- [Configuration](#configuration)
|
||||
- [Disable specific MCPs](#disable-specific-mcps)
|
||||
- [TL;DR](#tldr)
|
||||
- [Why OpenCode \& Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Disable specific Agents](#disable-specific-agents)
|
||||
- [Agent Configuration](#agent-configuration)
|
||||
- [Why OpenCode & Why Oh My OpenCode](#why-opencode--why-oh-my-opencode)
|
||||
- [Features](#features)
|
||||
- [Hooks](#hooks)
|
||||
- [Agents](#agents)
|
||||
@@ -33,6 +36,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,26 +59,66 @@ Add to `~/.config/opencode/opencode.json`:
|
||||
}
|
||||
```
|
||||
|
||||
## 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>
|
||||
|
||||
## 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
|
||||
Configuration supports autocomplete via schema. Details are covered in each feature section below.
|
||||
|
||||
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"
|
||||
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/dist/oh-my-opencode.schema.json"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -78,16 +132,103 @@ If you want to disable specific built-in MCPs, you can use the `disabled_mcps` o
|
||||
}
|
||||
```
|
||||
|
||||
## TL;DR
|
||||
### Disable specific Agents
|
||||
|
||||
- **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.
|
||||
If you want to disable specific built-in agents, you can use the `disabled_agents` option.
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_agents": ["explore", "frontend-ui-ux-engineer"]
|
||||
}
|
||||
```
|
||||
|
||||
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`
|
||||
|
||||
### Agent Configuration
|
||||
|
||||
You can override the configuration of any built-in agent using the `agents` option. This allows you to change models, adjust creativity, modify permissions, or disable agents individually.
|
||||
|
||||
#### Configuration Options
|
||||
|
||||
| Option | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `model` | string | Override the default model (e.g., "anthropic/claude-sonnet-4") |
|
||||
| `temperature` | number (0-2) | Controls randomness (0 = deterministic, 2 = creative) |
|
||||
| `top_p` | number (0-1) | Nucleus sampling parameter |
|
||||
| `prompt` | string | Additional system prompt to append |
|
||||
| `tools` | object | Enable/disable specific tools (e.g., `{"websearch_exa": false}`) |
|
||||
| `disable` | boolean | Completely disable the agent |
|
||||
| `description` | string | Override agent description |
|
||||
| `mode` | "subagent" | "primary" | "all" | When agent is available |
|
||||
| `color` | string | Hex color code for terminal output (e.g., "#FF0000") |
|
||||
| `permission` | object | Permission settings for sensitive operations |
|
||||
|
||||
#### Permission Options
|
||||
|
||||
| Option | Values | Description |
|
||||
|--------|--------|-------------|
|
||||
| `edit` | "ask" | "allow" | "deny" | File modification permissions |
|
||||
| `bash` | "ask" | "allow" | "deny" | object | Shell command execution permissions |
|
||||
| `webfetch` | "ask" | "allow" | "deny" | Web access permissions |
|
||||
| `doom_loop` | "ask" | "allow" | "deny" | Infinite loop prevention |
|
||||
| `external_directory` | "ask" | "allow" | "deny" | Access outside project root |
|
||||
|
||||
#### Examples
|
||||
|
||||
**Using Only Anthropic Models**
|
||||
|
||||
This configuration forces all agents to use Anthropic models, suitable for users with only Anthropic API access.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"oracle": {
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
},
|
||||
"librarian": {
|
||||
"model": "anthropic/claude-haiku-4-5"
|
||||
},
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5"
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
},
|
||||
"document-writer": {
|
||||
"model": "anthropic/claude-sonnet-4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Custom Agent with Additional Prompt**
|
||||
|
||||
Inject custom instructions into an agent's system prompt.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": {
|
||||
"prompt": "ALWAYS use Tailwind CSS. NEVER use inline styles. Prefer dark mode defaults.",
|
||||
"temperature": 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Disable Agents Individually**
|
||||
|
||||
You can also disable agents using the `disable` property within the agent config.
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"disable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Why OpenCode & Why Oh My OpenCode
|
||||
|
||||
@@ -124,6 +265,23 @@ 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.
|
||||
|
||||
#### Model Configuration Override
|
||||
|
||||
Agents follow the exact same model configuration spec as [OpenCode's model configuration](https://opencode.ai/docs/models/#configure-models). While not generally recommended, if you decide to use only Anthropic models, you could configure like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"explore": {
|
||||
"model": "anthropic/claude-haiku-4-5"
|
||||
},
|
||||
"frontend-ui-ux-engineer": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Tools
|
||||
|
||||
#### Built-in LSP Tools
|
||||
|
||||
11
bun.lock
11
bun.lock
@@ -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=="],
|
||||
}
|
||||
}
|
||||
|
||||
159
dist/oh-my-opencode.schema.json
vendored
Normal file
159
dist/oh-my-opencode.schema.json
vendored
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/dist/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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.11",
|
||||
"version": "0.1.15",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
267
src/hooks/comment-checker/cli.ts
Normal file
267
src/hooks/comment-checker/cli.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
import type { CommentInfo, CommentType } from "./types"
|
||||
import { getLanguageByExtension, QUERY_TEMPLATES, DOCSTRING_QUERIES } from "./constants"
|
||||
import * as fs from "fs"
|
||||
|
||||
// =============================================================================
|
||||
// Debug logging
|
||||
// =============================================================================
|
||||
|
||||
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:detector] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Parser Manager (LSP-style background initialization)
|
||||
// =============================================================================
|
||||
|
||||
interface ManagedLanguage {
|
||||
language: unknown
|
||||
initPromise?: Promise<unknown>
|
||||
isInitializing: boolean
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let parserClass: any = null
|
||||
let parserInitPromise: Promise<void> | null = null
|
||||
const languageCache = new Map<string, ManagedLanguage>()
|
||||
|
||||
const LANGUAGE_NAME_MAP: Record<string, string> = {
|
||||
golang: "go",
|
||||
csharp: "c_sharp",
|
||||
cpp: "cpp",
|
||||
}
|
||||
|
||||
const COMMON_LANGUAGES = [
|
||||
"python",
|
||||
"typescript",
|
||||
"javascript",
|
||||
"tsx",
|
||||
"go",
|
||||
"rust",
|
||||
"java",
|
||||
]
|
||||
|
||||
async function initParserClass(): Promise<void> {
|
||||
if (parserClass) return
|
||||
|
||||
if (parserInitPromise) {
|
||||
await parserInitPromise
|
||||
return
|
||||
}
|
||||
|
||||
parserInitPromise = (async () => {
|
||||
debugLog("importing web-tree-sitter...")
|
||||
parserClass = (await import("web-tree-sitter")).default
|
||||
const treeSitterWasmPath = require.resolve("web-tree-sitter/tree-sitter.wasm")
|
||||
debugLog("wasm path:", treeSitterWasmPath)
|
||||
await parserClass.init({
|
||||
locateFile: () => treeSitterWasmPath,
|
||||
})
|
||||
debugLog("Parser class initialized")
|
||||
})()
|
||||
|
||||
await parserInitPromise
|
||||
}
|
||||
|
||||
async function getParser() {
|
||||
await initParserClass()
|
||||
return new parserClass()
|
||||
}
|
||||
|
||||
async function loadLanguageWasm(langName: string): Promise<unknown | null> {
|
||||
const mappedLang = LANGUAGE_NAME_MAP[langName] || langName
|
||||
|
||||
try {
|
||||
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${langName}.wasm`)
|
||||
return wasmModule.default
|
||||
} catch {
|
||||
if (mappedLang !== langName) {
|
||||
try {
|
||||
const wasmModule = await import(`tree-sitter-wasms/out/tree-sitter-${mappedLang}.wasm`)
|
||||
return wasmModule.default
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function getLanguage(langName: string): Promise<unknown | null> {
|
||||
const cached = languageCache.get(langName)
|
||||
|
||||
if (cached) {
|
||||
if (cached.initPromise) {
|
||||
await cached.initPromise
|
||||
}
|
||||
cached.lastUsedAt = Date.now()
|
||||
debugLog("using cached language:", langName)
|
||||
return cached.language
|
||||
}
|
||||
|
||||
debugLog("loading language wasm:", langName)
|
||||
|
||||
const initPromise = (async () => {
|
||||
await initParserClass()
|
||||
const wasmPath = await loadLanguageWasm(langName)
|
||||
if (!wasmPath) {
|
||||
debugLog("failed to load language wasm:", langName)
|
||||
return null
|
||||
}
|
||||
return await parserClass!.Language.load(wasmPath)
|
||||
})()
|
||||
|
||||
languageCache.set(langName, {
|
||||
language: null as unknown,
|
||||
initPromise,
|
||||
isInitializing: true,
|
||||
lastUsedAt: Date.now(),
|
||||
})
|
||||
|
||||
const language = await initPromise
|
||||
const managed = languageCache.get(langName)
|
||||
if (managed) {
|
||||
managed.language = language
|
||||
managed.initPromise = undefined
|
||||
managed.isInitializing = false
|
||||
}
|
||||
|
||||
debugLog("language loaded and cached:", langName)
|
||||
return language
|
||||
}
|
||||
|
||||
function warmupLanguage(langName: string): void {
|
||||
if (languageCache.has(langName)) return
|
||||
|
||||
debugLog("warming up language (background):", langName)
|
||||
|
||||
const initPromise = (async () => {
|
||||
await initParserClass()
|
||||
const wasmPath = await loadLanguageWasm(langName)
|
||||
if (!wasmPath) return null
|
||||
return await parserClass!.Language.load(wasmPath)
|
||||
})()
|
||||
|
||||
languageCache.set(langName, {
|
||||
language: null as unknown,
|
||||
initPromise,
|
||||
isInitializing: true,
|
||||
lastUsedAt: Date.now(),
|
||||
})
|
||||
|
||||
initPromise.then((language) => {
|
||||
const managed = languageCache.get(langName)
|
||||
if (managed) {
|
||||
managed.language = language
|
||||
managed.initPromise = undefined
|
||||
managed.isInitializing = false
|
||||
debugLog("warmup complete:", langName)
|
||||
}
|
||||
}).catch((err) => {
|
||||
debugLog("warmup failed:", langName, err)
|
||||
languageCache.delete(langName)
|
||||
})
|
||||
}
|
||||
|
||||
export function warmupCommonLanguages(): void {
|
||||
debugLog("starting background warmup for common languages...")
|
||||
initParserClass().then(() => {
|
||||
for (const lang of COMMON_LANGUAGES) {
|
||||
warmupLanguage(lang)
|
||||
}
|
||||
}).catch((err) => {
|
||||
debugLog("warmup initialization failed:", err)
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Public API
|
||||
// =============================================================================
|
||||
|
||||
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[]> {
|
||||
debugLog("detectComments called:", { filePath, contentLength: content.length })
|
||||
|
||||
const langName = getLanguageByExtension(filePath)
|
||||
if (!langName) {
|
||||
debugLog("unsupported language for:", filePath)
|
||||
return []
|
||||
}
|
||||
|
||||
const queryPattern = QUERY_TEMPLATES[langName]
|
||||
if (!queryPattern) {
|
||||
debugLog("no query pattern for:", langName)
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const parser = await getParser()
|
||||
const language = await getLanguage(langName)
|
||||
|
||||
if (!language) {
|
||||
debugLog("language not available:", langName)
|
||||
return []
|
||||
}
|
||||
|
||||
parser.setLanguage(language)
|
||||
const tree = parser.parse(content)
|
||||
const comments: CommentInfo[] = []
|
||||
|
||||
const query = (language as { query: (pattern: string) => { matches: (node: unknown) => Array<{ captures: Array<{ node: { text: string; type: string; startPosition: { row: number } } }> }> } }).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 as { query: (pattern: string) => { matches: (node: unknown) => Array<{ captures: Array<{ node: { text: string; startPosition: { row: number } } }> }> } }).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)
|
||||
|
||||
debugLog("detected comments:", comments.length)
|
||||
return comments
|
||||
} catch (err) {
|
||||
debugLog("detectComments failed:", err)
|
||||
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,9 +1,8 @@
|
||||
import type { PendingCall, FileComments } from "./types"
|
||||
import { detectComments, isSupportedFile, warmupCommonLanguages } 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"
|
||||
@@ -18,6 +17,8 @@ function debugLog(...args: unknown[]) {
|
||||
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) {
|
||||
@@ -32,8 +33,14 @@ setInterval(cleanupOldPendingCalls, 10_000)
|
||||
export function createCommentCheckerHooks() {
|
||||
debugLog("createCommentCheckerHooks called")
|
||||
|
||||
// Background warmup - LSP style (non-blocking)
|
||||
warmupCommonLanguages()
|
||||
// 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 (
|
||||
@@ -50,6 +57,9 @@ export function createCommentCheckerHooks() {
|
||||
|
||||
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)
|
||||
|
||||
@@ -58,15 +68,13 @@ export function createCommentCheckerHooks() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isSupportedFile(filePath)) {
|
||||
debugLog("unsupported file:", 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(),
|
||||
@@ -89,7 +97,6 @@ export function createCommentCheckerHooks() {
|
||||
debugLog("processing pendingCall:", pendingCall)
|
||||
|
||||
// Only skip if the output indicates a tool execution failure
|
||||
// (not LSP warnings/errors or other incidental "error" strings)
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const isToolFailure =
|
||||
outputLower.includes("error:") ||
|
||||
@@ -103,40 +110,18 @@ export function createCommentCheckerHooks() {
|
||||
}
|
||||
|
||||
try {
|
||||
let content: string
|
||||
|
||||
if (pendingCall.content) {
|
||||
content = pendingCall.content
|
||||
debugLog("using content from args")
|
||||
} else {
|
||||
debugLog("reading file:", pendingCall.filePath)
|
||||
const file = Bun.file(pendingCall.filePath)
|
||||
content = await file.text()
|
||||
debugLog("file content length:", content.length)
|
||||
}
|
||||
|
||||
debugLog("calling detectComments...")
|
||||
const rawComments = await detectComments(pendingCall.filePath, content)
|
||||
debugLog("raw comments:", rawComments.length)
|
||||
// Wait for CLI path resolution
|
||||
const cliPath = await cliPathPromise
|
||||
|
||||
const filteredComments = applyFilters(rawComments)
|
||||
debugLog("filtered comments:", filteredComments.length)
|
||||
|
||||
if (filteredComments.length === 0) {
|
||||
debugLog("no comments after filtering")
|
||||
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)
|
||||
debugLog("appending message to output")
|
||||
output.output += `\n\n${message}`
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
@@ -144,4 +129,35 @@ export function createCommentCheckerHooks() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -211,29 +211,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
|
||||
|
||||
@@ -10,13 +10,55 @@ function showOutputToUser(context: unknown, output: string): void {
|
||||
ctx.metadata?.({ metadata: { output } })
|
||||
}
|
||||
|
||||
/**
|
||||
* JS/TS languages that require complete function declaration patterns
|
||||
*/
|
||||
const JS_TS_LANGUAGES = ["javascript", "typescript", "tsx"] as const
|
||||
|
||||
/**
|
||||
* Validates AST pattern for common incomplete patterns that will fail silently.
|
||||
* Only validates JS/TS languages where function declarations require body.
|
||||
*
|
||||
* @throws Error with helpful message if pattern is incomplete
|
||||
*/
|
||||
function validatePatternForCli(pattern: string, lang: CliLanguage): void {
|
||||
if (!JS_TS_LANGUAGES.includes(lang as (typeof JS_TS_LANGUAGES)[number])) {
|
||||
return
|
||||
}
|
||||
|
||||
const src = pattern.trim()
|
||||
|
||||
// Detect incomplete function declarations:
|
||||
// - "function $NAME" (no params/body)
|
||||
// - "export function $NAME" (no params/body)
|
||||
// - "export async function $NAME" (no params/body)
|
||||
// - "export default function $NAME" (no params/body)
|
||||
// Pattern: ends with $METAVAR (uppercase, underscore, digits) without ( or {
|
||||
const incompleteFunctionDecl =
|
||||
/^(export\s+)?(default\s+)?(async\s+)?function\s+\$[A-Z_][A-Z0-9_]*\s*$/i.test(src)
|
||||
|
||||
if (incompleteFunctionDecl) {
|
||||
throw new Error(
|
||||
`Incomplete AST pattern for ${lang}: "${pattern}"\n\n` +
|
||||
`ast-grep requires complete AST nodes. Function declarations must include parameters and body.\n\n` +
|
||||
`Examples of correct patterns:\n` +
|
||||
` - "export async function $NAME($$$) { $$$ }" (matches export async functions)\n` +
|
||||
` - "function $NAME($$$) { $$$ }" (matches all function declarations)\n` +
|
||||
` - "async function $NAME($$$) { $$$ }" (matches async functions)\n\n` +
|
||||
`Your pattern "${pattern}" is missing the parameter list and body.`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,6 +66,8 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
validatePatternForCli(args.pattern, args.lang as CliLanguage)
|
||||
|
||||
const matches = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
|
||||
Reference in New Issue
Block a user