mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-05 02:30:26 +08:00
Compare commits
182 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a989ce343c | ||
|
|
abe0839249 | ||
|
|
d75c973f32 | ||
|
|
e7f329940b | ||
|
|
0fc5eaaa2d | ||
|
|
420eb857ff | ||
|
|
661656c587 | ||
|
|
ed4b088631 | ||
|
|
55a574280a | ||
|
|
8f05626075 | ||
|
|
4395c5785d | ||
|
|
b0d7a09ff2 | ||
|
|
f7aeaa5c7e | ||
|
|
c8f75faf84 | ||
|
|
b8b06257ff | ||
|
|
369a3319f9 | ||
|
|
75f08ab81f | ||
|
|
23282ef460 | ||
|
|
c7cb28a1da | ||
|
|
0a4982e96d | ||
|
|
17e52d78d2 | ||
|
|
55246ce9c4 | ||
|
|
890fec81bf | ||
|
|
81f298c2ea | ||
|
|
8ea6d10be5 | ||
|
|
bdf62d0f1c | ||
|
|
40e2d00d35 | ||
|
|
13465b12e5 | ||
|
|
cf93a0ada9 | ||
|
|
b81953a1d7 | ||
|
|
1d2f28101a | ||
|
|
81e95777a8 | ||
|
|
993249acb1 | ||
|
|
0d28e70026 | ||
|
|
7560ce1976 | ||
|
|
683d18e6bb | ||
|
|
a7147f692c | ||
|
|
b71d74f01f | ||
|
|
af1c860f54 | ||
|
|
70b1896011 | ||
|
|
3fd3c67749 | ||
|
|
156a072a0b | ||
|
|
0ceb819419 | ||
|
|
4d69c8aef1 | ||
|
|
eec844d850 | ||
|
|
1f42bcc1c6 | ||
|
|
0f359b048f | ||
|
|
4e2df6a80e | ||
|
|
a30f434b5d | ||
|
|
41f4e21268 | ||
|
|
a67aa00c9a | ||
|
|
d61a0f9ffd | ||
|
|
fe5508228f | ||
|
|
50093036c3 | ||
|
|
0cae0ede08 | ||
|
|
4613b57240 | ||
|
|
7535a7b101 | ||
|
|
f6bb97eba9 | ||
|
|
78a411462b | ||
|
|
9471a981e3 | ||
|
|
3d27d44676 | ||
|
|
6a66c9741f | ||
|
|
a09c103cfb | ||
|
|
1dec763e26 | ||
|
|
f57ea2df59 | ||
|
|
d215c33549 | ||
|
|
b3f8fcfea6 | ||
|
|
806bb04a35 | ||
|
|
b1156038de | ||
|
|
0c93bbe574 | ||
|
|
6f4f4e701b | ||
|
|
ff301507fe | ||
|
|
93b72eba42 | ||
|
|
b01758e7e1 | ||
|
|
c51b38c671 | ||
|
|
b227fee225 | ||
|
|
2b7569335b | ||
|
|
9e667f0895 | ||
|
|
4759eb2c42 | ||
|
|
edbf168b57 | ||
|
|
9bfea81ca6 | ||
|
|
a9bcea45f5 | ||
|
|
8554da6e2f | ||
|
|
b2f941af5f | ||
|
|
6861a9d057 | ||
|
|
18189f095c | ||
|
|
f1c306cb23 | ||
|
|
0dc6df4e71 | ||
|
|
21bb45a7af | ||
|
|
e7464d1286 | ||
|
|
373d75cc36 | ||
|
|
0bbcc6c68e | ||
|
|
3c6f22ca48 | ||
|
|
87016ce331 | ||
|
|
86d18ca19a | ||
|
|
4edd2d2d2d | ||
|
|
ef47ed57e9 | ||
|
|
b2e3f416bc | ||
|
|
7231c6d2c4 | ||
|
|
fa342f98c2 | ||
|
|
90478d2049 | ||
|
|
e1ad08fcc1 | ||
|
|
cf2e4fefa4 | ||
|
|
d7bb28a9ce | ||
|
|
b41b223fc8 | ||
|
|
a86ee9340c | ||
|
|
c6cd20d2fd | ||
|
|
132df6cb28 | ||
|
|
d7c514e869 | ||
|
|
3ef288bfaa | ||
|
|
d86a5b67b6 | ||
|
|
8f3941adae | ||
|
|
18c6c32628 | ||
|
|
1ad2cfe629 | ||
|
|
7bad716fbc | ||
|
|
220be6eb5c | ||
|
|
ead11d6996 | ||
|
|
fec4b7dba3 | ||
|
|
da257b860b | ||
|
|
9452b77307 | ||
|
|
85303126d6 | ||
|
|
f08fa88d71 | ||
|
|
33149d9615 | ||
|
|
95408e7fa7 | ||
|
|
22987b5f74 | ||
|
|
90f9a131fe | ||
|
|
017ad5e4d9 | ||
|
|
15b4176afb | ||
|
|
1533e08425 | ||
|
|
c3dd5b567f | ||
|
|
386937cfb3 | ||
|
|
c89ad3df2d | ||
|
|
2b8efd42a9 | ||
|
|
d4104214ff | ||
|
|
802efb5358 | ||
|
|
767b137c58 | ||
|
|
8eecf103ef | ||
|
|
77822cf062 | ||
|
|
007c27879d | ||
|
|
368831da4c | ||
|
|
eb84dfa574 | ||
|
|
3bc8342929 | ||
|
|
cfc64e8515 | ||
|
|
7a40c9d492 | ||
|
|
d51a2f12f8 | ||
|
|
8a8771076d | ||
|
|
e637b26151 | ||
|
|
595fa8da96 | ||
|
|
9ba6950d21 | ||
|
|
7f790fbe15 | ||
|
|
06f14aa695 | ||
|
|
9fa872a1f0 | ||
|
|
6d263fe8c9 | ||
|
|
e55b13c2c5 | ||
|
|
f95f5f5e88 | ||
|
|
246674c388 | ||
|
|
23c212f8be | ||
|
|
90477abb81 | ||
|
|
11afae2dff | ||
|
|
3df4fec6dd | ||
|
|
aea19f0e1f | ||
|
|
291a4e3d0a | ||
|
|
957b737126 | ||
|
|
3e30f4e207 | ||
|
|
b172343235 | ||
|
|
c8a652ec15 | ||
|
|
12e47affa9 | ||
|
|
612150f72e | ||
|
|
77d9870094 | ||
|
|
c96c07be2a | ||
|
|
cee467fc0e | ||
|
|
71305da77e | ||
|
|
c4021cf58a | ||
|
|
9a18a03061 | ||
|
|
b5183c7711 | ||
|
|
3fab18a6bb | ||
|
|
12af992d8c | ||
|
|
bbd2f50c38 | ||
|
|
3f7652f992 | ||
|
|
2cbe36b532 | ||
|
|
fdb152872d | ||
|
|
916b970665 |
@@ -1,231 +1,47 @@
|
||||
{
|
||||
"name": "claude-code-dev-workflows",
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "myclaude",
|
||||
"version": "5.6.1",
|
||||
"description": "Professional multi-agent development workflows with OmO orchestration, Requirements-Driven and BMAD methodologies",
|
||||
"owner": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"email": "contact@example.com",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Professional multi-agent development workflows with Requirements-Driven and BMAD methodologies, featuring 16+ specialized agents and 12+ commands",
|
||||
"version": "1.0.0"
|
||||
"name": "cexll",
|
||||
"email": "evanxian9@gmail.com"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "requirements-driven-development",
|
||||
"source": "./requirements-driven-workflow/",
|
||||
"description": "Streamlined requirements-driven development workflow with 90% quality gates for practical feature implementation",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"requirements",
|
||||
"workflow",
|
||||
"automation",
|
||||
"quality-gates",
|
||||
"feature-development",
|
||||
"agile",
|
||||
"specifications"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/requirements-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/requirements-generate.md",
|
||||
"./agents/requirements-code.md",
|
||||
"./agents/requirements-testing.md",
|
||||
"./agents/requirements-review.md"
|
||||
]
|
||||
"name": "omo",
|
||||
"description": "Multi-agent orchestration for code analysis, bug investigation, fix planning, and implementation with intelligent routing to specialized agents",
|
||||
"version": "5.6.1",
|
||||
"source": "./skills/omo",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "bmad-agile-workflow",
|
||||
"source": "./bmad-agile-workflow/",
|
||||
"name": "dev",
|
||||
"description": "Lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
|
||||
"version": "5.6.1",
|
||||
"source": "./dev-workflow",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "requirements",
|
||||
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
|
||||
"version": "5.6.1",
|
||||
"source": "./requirements-driven-workflow",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "bmad",
|
||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"bmad",
|
||||
"agile",
|
||||
"scrum",
|
||||
"product-owner",
|
||||
"architect",
|
||||
"developer",
|
||||
"qa",
|
||||
"workflow-orchestration"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/bmad-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/bmad-po.md",
|
||||
"./agents/bmad-architect.md",
|
||||
"./agents/bmad-sm.md",
|
||||
"./agents/bmad-dev.md",
|
||||
"./agents/bmad-qa.md",
|
||||
"./agents/bmad-orchestrator.md",
|
||||
"./agents/bmad-review.md"
|
||||
]
|
||||
"version": "5.6.1",
|
||||
"source": "./bmad-agile-workflow",
|
||||
"category": "development"
|
||||
},
|
||||
{
|
||||
"name": "development-essentials",
|
||||
"source": "./development-essentials/",
|
||||
"name": "dev-kit",
|
||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"code",
|
||||
"debug",
|
||||
"test",
|
||||
"optimize",
|
||||
"review",
|
||||
"bugfix",
|
||||
"refactor",
|
||||
"documentation"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/code.md",
|
||||
"./commands/debug.md",
|
||||
"./commands/test.md",
|
||||
"./commands/optimize.md",
|
||||
"./commands/review.md",
|
||||
"./commands/bugfix.md",
|
||||
"./commands/refactor.md",
|
||||
"./commands/docs.md",
|
||||
"./commands/ask.md",
|
||||
"./commands/think.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/code.md",
|
||||
"./agents/bugfix.md",
|
||||
"./agents/bugfix-verify.md",
|
||||
"./agents/optimize.md",
|
||||
"./agents/debug.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "advanced-ai-agents",
|
||||
"source": "./advanced-ai-agents/",
|
||||
"description": "Advanced AI agent for complex problem solving and deep analysis with GPT-5 integration",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"gpt5",
|
||||
"ai",
|
||||
"analysis",
|
||||
"problem-solving",
|
||||
"deep-research"
|
||||
],
|
||||
"category": "advanced",
|
||||
"strict": false,
|
||||
"commands": [],
|
||||
"agents": [
|
||||
"./agents/gpt5.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "requirements-clarity",
|
||||
"source": "./requirements-clarity/",
|
||||
"description": "Transforms vague requirements into actionable PRDs through systematic clarification with 100-point scoring system",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"requirements",
|
||||
"clarification",
|
||||
"prd",
|
||||
"specifications",
|
||||
"quality-gates",
|
||||
"requirements-engineering"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./skills/SKILL.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "codex-cli",
|
||||
"source": "./skills/codex/",
|
||||
"description": "Execute Codex CLI for code analysis, refactoring, and automated code changes with file references (@syntax) and structured output",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"codex",
|
||||
"code-analysis",
|
||||
"refactoring",
|
||||
"automation",
|
||||
"gpt-5",
|
||||
"ai-coding"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./SKILL.md"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "gemini-cli",
|
||||
"source": "./skills/gemini/",
|
||||
"description": "Execute Gemini CLI for AI-powered code analysis and generation with Google's latest Gemini models",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"gemini",
|
||||
"google-ai",
|
||||
"code-analysis",
|
||||
"code-generation",
|
||||
"ai-reasoning"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./SKILL.md"
|
||||
]
|
||||
"version": "5.6.1",
|
||||
"source": "./development-essentials",
|
||||
"category": "productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
22
.gitattributes
vendored
Normal file
22
.gitattributes
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
# Ensure shell scripts always use LF line endings on all platforms
|
||||
*.sh text eol=lf
|
||||
|
||||
# Ensure Python files use LF line endings
|
||||
*.py text eol=lf
|
||||
|
||||
# Auto-detect text files and normalize line endings to LF
|
||||
* text=auto eol=lf
|
||||
|
||||
# Explicitly declare files that should always be treated as binary
|
||||
*.exe binary
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.mov binary
|
||||
*.mp4 binary
|
||||
*.mp3 binary
|
||||
*.zip binary
|
||||
*.gz binary
|
||||
*.tar binary
|
||||
34
.github/workflows/ci.yml
vendored
Normal file
34
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master, rc/*]
|
||||
pull_request:
|
||||
branches: [master, rc/*]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
cd codeagent-wrapper
|
||||
go test -v -cover -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Check coverage
|
||||
run: |
|
||||
cd codeagent-wrapper
|
||||
go tool cover -func=coverage.out | grep total | awk '{print $3}'
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: codeagent-wrapper/coverage.out
|
||||
continue-on-error: true
|
||||
113
.github/workflows/release.yml
vendored
Normal file
113
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: Release codeagent-wrapper
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Run tests
|
||||
working-directory: codeagent-wrapper
|
||||
run: go test -v -coverprofile=cover.out ./...
|
||||
|
||||
- name: Check coverage
|
||||
working-directory: codeagent-wrapper
|
||||
run: |
|
||||
go tool cover -func=cover.out | grep total
|
||||
COVERAGE=$(go tool cover -func=cover.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||
echo "Coverage: ${COVERAGE}%"
|
||||
|
||||
build:
|
||||
name: Build
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- goos: linux
|
||||
goarch: amd64
|
||||
- goos: linux
|
||||
goarch: arm64
|
||||
- goos: darwin
|
||||
goarch: amd64
|
||||
- goos: darwin
|
||||
goarch: arm64
|
||||
- goos: windows
|
||||
goarch: amd64
|
||||
- goos: windows
|
||||
goarch: arm64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
|
||||
- name: Build binary
|
||||
id: build
|
||||
working-directory: codeagent-wrapper
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
GOARCH: ${{ matrix.goarch }}
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
OUTPUT_NAME=codeagent-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||
fi
|
||||
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} .
|
||||
chmod +x ${OUTPUT_NAME}
|
||||
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: codeagent-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||
path: ${{ steps.build.outputs.artifact_path }}
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Prepare release files
|
||||
run: |
|
||||
mkdir -p release
|
||||
find artifacts -type f -name "codeagent-wrapper-*" -exec mv {} release/ \;
|
||||
cp install.sh install.bat release/
|
||||
ls -la release/
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,3 +1,9 @@
|
||||
CLAUDE.md
|
||||
.claude/
|
||||
.claude-trace
|
||||
.DS_Store
|
||||
**/.DS_Store
|
||||
.venv
|
||||
.pytest_cache
|
||||
__pycache__
|
||||
.coverage
|
||||
coverage.out
|
||||
|
||||
772
CHANGELOG.md
Normal file
772
CHANGELOG.md
Normal file
@@ -0,0 +1,772 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [5.6.4] - 2026-01-15
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- add reasoning effort config for codex backend
|
||||
- default to skip-permissions and bypass-sandbox
|
||||
- add multi-agent support with yolo mode
|
||||
- add omo module for multi-agent orchestration
|
||||
- add intelligent backend selection based on task complexity (#61)
|
||||
- v5.4.0 structured execution report (#94)
|
||||
- add millisecond-precision timestamps to all log entries (#91)
|
||||
- skill-install install script and security scan
|
||||
- add uninstall scripts with selective module removal
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- filter codex stderr noise logs
|
||||
- use config override for codex reasoning effort
|
||||
- propagate SkipPermissions to parallel tasks (#113)
|
||||
- add timeout for Windows process termination
|
||||
- reject dash as workdir parameter (#118)
|
||||
- add sleep in fake script to prevent CI race condition
|
||||
- fix gemini env load
|
||||
- fix omo
|
||||
- fix codeagent skill TaskOutput
|
||||
- 修复 Gemini init 事件 session_id 未提取的问题 (#111)
|
||||
- Windows 后端退出:taskkill 结束进程树 + turn.completed 支持 (#108)
|
||||
- support model parameter for all backends, auto-inject from settings (#105)
|
||||
- replace setx with reg add to avoid 1024-char PATH truncation (#101)
|
||||
- 移除未知事件格式的日志噪声 (#96)
|
||||
- prevent duplicate PATH entries on reinstall (#95)
|
||||
- Minor issues #12 and #13 - ASCII mode and performance optimization
|
||||
- correct settings.json filename and bump version to v5.2.8
|
||||
- allow claude backend to read env from setting.json while preventing recursion (#92)
|
||||
- comprehensive security and quality improvements for PR #85 & #87 (#90)
|
||||
- Improve backend termination after message and extend timeout (#86)
|
||||
- Parser重复解析优化 + 严重bug修复 + PR #86兼容性 (#88)
|
||||
- filter noisy stderr output from gemini backend (#83)
|
||||
- 修復 wsl install.sh 格式問題 (#78)
|
||||
- 修复多 backend 并行日志 PID 混乱并移除包装格式 (#74) (#76)
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
- remove sisyphus agent and unused code
|
||||
- streamline agent documentation and remove sisyphus
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
- add OmO workflow to README and fix plugin marketplace structure
|
||||
- update FAQ for default bypass/skip-permissions behavior
|
||||
- 添加 FAQ 常见问题章节
|
||||
- update troubleshooting with idempotent PATH commands (#95)
|
||||
|
||||
### 💼 Other
|
||||
|
||||
- add test-cases skill
|
||||
- add browser skill
|
||||
- BMADh和Requirements-Driven支持根据语义生成对应的文档 (#82)
|
||||
- update all readme
|
||||
|
||||
## [5.2.4] - 2025-12-16
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- integrate git-cliff for automated changelog generation
|
||||
|
||||
- bump version to 5.2.4
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 防止 Claude backend 无限递归调用
|
||||
|
||||
- isolate log files per task in parallel mode
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #70 from cexll/fix/prevent-codeagent-infinite-recursion
|
||||
|
||||
- Merge pull request #69 from cexll/myclaude-master-20251215-073053-338465000
|
||||
|
||||
- update CHANGELOG.md
|
||||
|
||||
- Merge pull request #65 from cexll/fix/issue-64-buffer-overflow
|
||||
|
||||
## [5.2.3] - 2025-12-15
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 修复 bufio.Scanner token too long 错误 ([#64](https://github.com/cexll/myclaude/issues/64))
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- change version
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 同步测试中的版本号至 5.2.3
|
||||
|
||||
## [5.2.2] - 2025-12-13
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- Bump version and clean up documentation
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codeagent backend claude no auto
|
||||
|
||||
- fix install.py dev fail
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- Fix tests for ClaudeBackend default --dangerously-skip-permissions
|
||||
|
||||
## [5.2.1] - 2025-12-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codeagent claude and gemini root dir
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update readme
|
||||
|
||||
## [5.2.0] - 2025-12-13
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- Update CHANGELOG and remove deprecated test files
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix race condition in stdout parsing
|
||||
|
||||
- add worker limit cap and remove legacy alias
|
||||
|
||||
- use -r flag for gemini backend resume
|
||||
|
||||
- clarify module list shows default state not enabled
|
||||
|
||||
- use -r flag for claude backend resume
|
||||
|
||||
- remove binary artifacts and improve error messages
|
||||
|
||||
- 异常退出时显示最近错误信息
|
||||
|
||||
- op_run_command 实时流式输出
|
||||
|
||||
- 修复权限标志逻辑和版本号测试
|
||||
|
||||
- 重构信号处理逻辑避免重复 nil 检查
|
||||
|
||||
- 移除 .claude 配置文件验证步骤
|
||||
|
||||
- 修复并行执行启动横幅重复打印问题
|
||||
|
||||
- 修复master合并后的编译和测试问题
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge rc/5.2 into master: v5.2.0 release improvements
|
||||
|
||||
- Merge pull request #53 from cexll/rc/5.2
|
||||
|
||||
- remove docs
|
||||
|
||||
- remove docs
|
||||
|
||||
- add prototype prompt skill
|
||||
|
||||
- add prd skill
|
||||
|
||||
- update memory claude
|
||||
|
||||
- remove command gh flow
|
||||
|
||||
- update license
|
||||
|
||||
- Merge branch 'master' into rc/5.2
|
||||
|
||||
- Merge pull request #52 from cexll/fix/parallel-log-path-on-startup
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- remove GitHub workflow related content
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- Complete skills system integration and config cleanup
|
||||
|
||||
- Improve release notes and installation scripts
|
||||
|
||||
- 添加终端日志输出和 verbose 模式
|
||||
|
||||
- 完整多后端支持与安全优化
|
||||
|
||||
- 替换 Codex 为 codeagent 并添加 UI 自动检测
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- 调整文件命名和技能定义
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 添加 ExtractRecentErrors 单元测试
|
||||
|
||||
## [5.1.4] - 2025-12-09
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 任务启动时立即返回日志文件路径以支持实时调试
|
||||
|
||||
## [5.1.3] - 2025-12-08
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- resolve CI timing race in TestFakeCmdInfra
|
||||
|
||||
## [5.1.2] - 2025-12-08
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 修复channel同步竞态条件和死锁问题
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #51 from cexll/fix/channel-sync-race-conditions
|
||||
|
||||
- change codex-wrapper version
|
||||
|
||||
## [5.1.1] - 2025-12-08
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 增强日志清理的安全性和可靠性
|
||||
|
||||
- resolve data race on forceKillDelay with atomic operations
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #49 from cexll/freespace8/master
|
||||
|
||||
- resolve signal handling conflict preserving testability and Windows support
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 补充测试覆盖提升至 89.3%
|
||||
|
||||
## [5.1.0] - 2025-12-07
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #45 from Michaelxwb/master
|
||||
|
||||
- 修改windows安装说明
|
||||
|
||||
- 修改打包脚本
|
||||
|
||||
- 支持windows系统的安装
|
||||
|
||||
- Merge pull request #1 from Michaelxwb/feature-win
|
||||
|
||||
- 支持window
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- 添加启动时清理日志的功能和--cleanup标志支持
|
||||
|
||||
- implement enterprise workflow with multi-backend support
|
||||
|
||||
## [5.0.0] - 2025-12-05
|
||||
|
||||
|
||||
### ⚙️ Miscellaneous Tasks
|
||||
|
||||
|
||||
- clarify unit-test coverage levels in requirement questions
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- defer startup log until args parsed
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Merge pull request #43 from gurdasnijor/smithery/add-badge
|
||||
|
||||
- Add Smithery badge
|
||||
|
||||
- Merge pull request #42 from freespace8/master
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- rewrite documentation for v5.0 modular architecture
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat install.py
|
||||
|
||||
- implement modular installation system
|
||||
|
||||
### 🚜 Refactor
|
||||
|
||||
|
||||
- remove deprecated plugin modules
|
||||
|
||||
## [4.8.2] - 2025-12-02
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- skip signal test in CI environment
|
||||
|
||||
- make forceKillDelay testable to prevent signal test timeout
|
||||
|
||||
- correct Go version in go.mod from 1.25.3 to 1.21
|
||||
|
||||
- fix codex wrapper async log
|
||||
|
||||
- capture and include stderr in error messages
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #41 from cexll/fix-async-log
|
||||
|
||||
- remove test case 90
|
||||
|
||||
- optimize codex-wrapper
|
||||
|
||||
- Merge branch 'master' into fix-async-log
|
||||
|
||||
## [4.8.1] - 2025-12-01
|
||||
|
||||
|
||||
### 🎨 Styling
|
||||
|
||||
|
||||
- replace emoji with text labels
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- improve --parallel parameter validation and docs
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- remove codex-wrapper bin
|
||||
|
||||
## [4.8.0] - 2025-11-30
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update codex skill dependencies
|
||||
|
||||
## [4.7.3] - 2025-11-29
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- 保留日志文件以便程序退出后调试并完善日志输出功能
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #34 from cexll/cce-worktree-master-20251129-111802-997076000
|
||||
|
||||
- update CLAUDE.md and codex skill
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- improve codex skill parameter best practices
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- add session resume support and improve output format
|
||||
|
||||
- add parallel execution support to codex-wrapper
|
||||
|
||||
- add async logging to temp file with lifecycle management
|
||||
|
||||
## [4.7.2] - 2025-11-28
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- improve buffer size and streamline message extraction
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #32 from freespace8/master
|
||||
|
||||
### 🧪 Testing
|
||||
|
||||
|
||||
- 增加对超大单行文本和非字符串文本的处理测试
|
||||
|
||||
## [4.7.1] - 2025-11-27
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- optimize dev pipline
|
||||
|
||||
- Merge feat/codex-wrapper: fix repository URLs
|
||||
|
||||
## [4.7] - 2025-11-27
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- update repository URLs to cexll/myclaude
|
||||
|
||||
## [4.7-alpha1] - 2025-11-27
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix marketplace schema validation error in dev-workflow plugin
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #29 from cexll/feat/codex-wrapper
|
||||
|
||||
- Add codex-wrapper Go implementation
|
||||
|
||||
- update readme
|
||||
|
||||
- update readme
|
||||
|
||||
## [4.6] - 2025-11-25
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update dev workflow
|
||||
|
||||
- update dev workflow
|
||||
|
||||
## [4.5] - 2025-11-25
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex skill eof
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update dev workflow plugin
|
||||
|
||||
- update readme
|
||||
|
||||
## [4.4] - 2025-11-22
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex skill timeout and add more log
|
||||
|
||||
- fix codex skill
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update gemini skills
|
||||
|
||||
- update dev workflow
|
||||
|
||||
- update codex skills model config
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Merge pull request #24 from cexll/swe-agent/23-1763544297
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- 支持通过环境变量配置 skills 模型
|
||||
|
||||
## [4.3] - 2025-11-19
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex skills running
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update skills plugin
|
||||
|
||||
- update gemini
|
||||
|
||||
- update doc
|
||||
|
||||
- Add Gemini CLI integration skill
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat simple dev workflow
|
||||
|
||||
## [4.2.2] - 2025-11-15
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update codex skills
|
||||
|
||||
## [4.2.1] - 2025-11-14
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #21 from Tshoiasc/master
|
||||
|
||||
- Merge branch 'master' into master
|
||||
|
||||
- Change default model to gpt-5.1-codex
|
||||
|
||||
- Enhance codex.py to auto-detect long inputs and switch to stdin mode, improving handling of shell argument issues. Updated build_codex_args to support stdin and added relevant logging for task length warnings.
|
||||
|
||||
## [4.2] - 2025-11-13
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix codex.py wsl run err
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- optimize codex skills
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Rename SKILLS.md to SKILL.md
|
||||
|
||||
- optimize codex skills
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
|
||||
- feat codex skills
|
||||
|
||||
## [4.1] - 2025-11-04
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update enhance-prompt.md response
|
||||
|
||||
- update readme
|
||||
|
||||
### 📚 Documentation
|
||||
|
||||
|
||||
- 新增 /enhance-prompt 命令并更新所有 README 文档
|
||||
|
||||
## [4.0] - 2025-10-22
|
||||
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fix skills format
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge branch 'master' of github.com:cexll/myclaude
|
||||
|
||||
- Merge pull request #18 from cexll/swe-agent/17-1760969135
|
||||
|
||||
- update requirements clarity
|
||||
|
||||
- update .gitignore
|
||||
|
||||
- Fix #17: Update root marketplace.json to use skills array
|
||||
|
||||
- Fix #17: Convert requirements-clarity to correct plugin directory format
|
||||
|
||||
- Fix #17: Convert requirements-clarity to correct plugin directory format
|
||||
|
||||
- Convert requirements-clarity to plugin format with English prompts
|
||||
|
||||
- Translate requirements-clarity skill to English for plugin compatibility
|
||||
|
||||
- Add requirements-clarity Claude Skill
|
||||
|
||||
- Add requirements clarification command
|
||||
|
||||
- update
|
||||
|
||||
## [3.5] - 2025-10-20
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #15 from cexll/swe-agent/13-1760944712
|
||||
|
||||
- Fix #13: Clean up redundant README files
|
||||
|
||||
- Optimize README structure - Solution A (modular)
|
||||
|
||||
- Merge pull request #14 from cexll/swe-agent/12-1760944588
|
||||
|
||||
- Fix #12: Update Makefile install paths for new directory structure
|
||||
|
||||
## [3.4] - 2025-10-20
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Merge pull request #11 from cexll/swe-agent/10-1760752533
|
||||
|
||||
- Fix marketplace metadata references
|
||||
|
||||
- Fix plugin configuration: rename to marketplace.json and update repository URLs
|
||||
|
||||
- Fix #10: Restructure plugin directories to ensure proper command isolation
|
||||
|
||||
## [3.3] - 2025-10-15
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Update README-zh.md
|
||||
|
||||
- Update README.md
|
||||
|
||||
- Update marketplace.json
|
||||
|
||||
- Update Chinese README with v3.2 plugin system documentation
|
||||
|
||||
- Update README with v3.2 plugin system documentation
|
||||
|
||||
## [3.2] - 2025-10-10
|
||||
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- Add Claude Code plugin system support
|
||||
|
||||
- update readme
|
||||
|
||||
- Add Makefile for quick deployment and update READMEs
|
||||
|
||||
## [3.1] - 2025-09-17
|
||||
|
||||
|
||||
### ◀️ Revert
|
||||
|
||||
|
||||
- revert
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
|
||||
- fixed bmad-orchestrator not fund
|
||||
|
||||
- fix bmad
|
||||
|
||||
### 💼 Other
|
||||
|
||||
|
||||
- update bmad review with codex support
|
||||
|
||||
- 优化 BMAD 工作流和代理配置
|
||||
|
||||
- update gpt5
|
||||
|
||||
- support bmad output-style
|
||||
|
||||
- update bmad user guide
|
||||
|
||||
- update bmad readme
|
||||
|
||||
- optimize requirements pilot
|
||||
|
||||
- add use gpt5 codex
|
||||
|
||||
- add bmad pilot
|
||||
|
||||
- sync READMEs with actual commands/agents; remove nonexistent commands; enhance requirements-pilot with testing decision gate and options.
|
||||
|
||||
- Update Chinese README and requirements-pilot command to align with latest workflow
|
||||
|
||||
- update readme
|
||||
|
||||
- update agent
|
||||
|
||||
- update bugfix sub agents
|
||||
|
||||
- Update ask support KISS YAGNI SOLID
|
||||
|
||||
- Add comprehensive documentation and multi-agent workflow system
|
||||
|
||||
- update commands
|
||||
<!-- generated by git-cliff -->
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero General Public License from time to time. Such new versions
|
||||
will be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
25
Makefile
25
Makefile
@@ -1,16 +1,18 @@
|
||||
# Claude Code Multi-Agent Workflow System Makefile
|
||||
# Quick deployment for BMAD and Requirements workflows
|
||||
|
||||
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test
|
||||
.PHONY: help install deploy-bmad deploy-requirements deploy-essentials deploy-advanced deploy-all deploy-commands deploy-agents clean test changelog
|
||||
|
||||
# Default target
|
||||
help:
|
||||
@echo "Claude Code Multi-Agent Workflow - Quick Deployment"
|
||||
@echo ""
|
||||
@echo "Recommended installation: python3 install.py --install-dir ~/.claude"
|
||||
@echo ""
|
||||
@echo "Usage: make [target]"
|
||||
@echo ""
|
||||
@echo "Targets:"
|
||||
@echo " install - Install all configurations to Claude Code"
|
||||
@echo " install - LEGACY: install all configurations (prefer install.py)"
|
||||
@echo " deploy-bmad - Deploy BMAD workflow (bmad-pilot)"
|
||||
@echo " deploy-requirements - Deploy Requirements workflow (requirements-pilot)"
|
||||
@echo " deploy-essentials - Deploy Development Essentials workflow"
|
||||
@@ -20,6 +22,7 @@ help:
|
||||
@echo " deploy-all - Deploy everything (commands + agents)"
|
||||
@echo " test-bmad - Test BMAD workflow with sample"
|
||||
@echo " test-requirements - Test Requirements workflow with sample"
|
||||
@echo " changelog - Update CHANGELOG.md using git-cliff"
|
||||
@echo " clean - Clean generated artifacts"
|
||||
@echo " help - Show this help message"
|
||||
|
||||
@@ -36,6 +39,8 @@ OUTPUT_STYLES_DIR = output-styles
|
||||
|
||||
# Install all configurations
|
||||
install: deploy-all
|
||||
@echo "⚠️ LEGACY PATH: make install will be removed in future versions."
|
||||
@echo " Prefer: python3 install.py --install-dir ~/.claude"
|
||||
@echo "✅ Installation complete!"
|
||||
|
||||
# Deploy BMAD workflow
|
||||
@@ -140,4 +145,18 @@ all: deploy-all
|
||||
# Version info
|
||||
version:
|
||||
@echo "Claude Code Multi-Agent Workflow System v3.1"
|
||||
@echo "BMAD + Requirements-Driven Development"
|
||||
@echo "BMAD + Requirements-Driven Development"
|
||||
|
||||
# Update CHANGELOG.md using git-cliff
|
||||
changelog:
|
||||
@echo "📝 Updating CHANGELOG.md with git-cliff..."
|
||||
@if ! command -v git-cliff > /dev/null 2>&1; then \
|
||||
echo "❌ git-cliff not found. Installing via Homebrew..."; \
|
||||
brew install git-cliff; \
|
||||
fi
|
||||
@git-cliff -o CHANGELOG.md
|
||||
@echo "✅ CHANGELOG.md updated successfully!"
|
||||
@echo ""
|
||||
@echo "Preview the changes:"
|
||||
@echo " git diff CHANGELOG.md"
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
# Claude Code Plugin System
|
||||
|
||||
本项目已支持Claude Code插件系统,可以将命令和代理打包成可安装的插件包。
|
||||
|
||||
## 插件配置
|
||||
|
||||
插件配置文件位于 `.claude-plugin/marketplace.json`,定义了所有可用的插件包。
|
||||
|
||||
## 可用插件
|
||||
|
||||
### 1. Requirements-Driven Development
|
||||
- **描述**: 需求驱动的开发工作流,包含90%质量门控
|
||||
- **命令**: `/requirements-pilot`
|
||||
- **代理**: requirements-generate, requirements-code, requirements-testing, requirements-review
|
||||
|
||||
### 2. BMAD Agile Workflow
|
||||
- **描述**: 完整的BMAD敏捷工作流(产品负责人→架构师→SM→开发→QA)
|
||||
- **命令**: `/bmad-pilot`
|
||||
- **代理**: bmad-po, bmad-architect, bmad-sm, bmad-dev, bmad-qa, bmad-orchestrator
|
||||
|
||||
### 3. Development Essentials
|
||||
- **描述**: 核心开发命令套件
|
||||
- **命令**: `/code`, `/debug`, `/test`, `/optimize`, `/review`, `/bugfix`, `/refactor`, `/docs`, `/ask`, `/think`
|
||||
- **代理**: code, bugfix, bugfix-verify, code-optimize, debug, develop
|
||||
|
||||
### 4. Advanced AI Agents
|
||||
- **描述**: 高级AI代理,集成GPT-5进行深度分析
|
||||
- **代理**: gpt5
|
||||
|
||||
## 使用插件命令
|
||||
|
||||
### 列出所有可用插件
|
||||
```bash
|
||||
/plugin list
|
||||
```
|
||||
|
||||
### 查看插件详情
|
||||
```bash
|
||||
/plugin info <plugin-name>
|
||||
```
|
||||
例如:`/plugin info requirements-driven-development`
|
||||
|
||||
### 安装插件
|
||||
```bash
|
||||
/plugin install <plugin-name>
|
||||
```
|
||||
例如:`/plugin install bmad-agile-workflow`
|
||||
|
||||
### 移除插件
|
||||
```bash
|
||||
/plugin remove <plugin-name>
|
||||
```
|
||||
|
||||
## 创建自定义插件
|
||||
|
||||
要创建自己的插件:
|
||||
|
||||
1. 在 `.claude-plugin/marketplace.json` 中添加新的插件定义
|
||||
2. 指定插件包含的命令和代理文件路径
|
||||
3. 设置适当的元数据(版本、作者、关键词等)
|
||||
|
||||
示例插件结构:
|
||||
```json
|
||||
{
|
||||
"name": "my-custom-plugin",
|
||||
"source": "./",
|
||||
"description": "自定义插件描述",
|
||||
"version": "1.0.0",
|
||||
"commands": [
|
||||
"./commands/my-command.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/my-agent.md"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## 分享插件
|
||||
|
||||
要分享插件给其他项目:
|
||||
1. 复制整个 `.claude-plugin` 目录到目标项目
|
||||
2. 确保相关的命令和代理文件存在
|
||||
3. 在新项目中使用 `/plugin` 命令管理插件
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 插件系统遵循Claude Code的插件规范
|
||||
- 所有命令和代理文件必须是有效的Markdown格式
|
||||
- 插件配置支持版本管理和依赖关系
|
||||
- 插件可以包含多个命令、代理和输出样式
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [Claude Code插件文档](https://docs.claude.com/en/docs/claude-code/plugins)
|
||||
- [示例插件仓库](https://github.com/wshobson/agents)
|
||||
694
README.md
694
README.md
@@ -1,121 +1,621 @@
|
||||
[中文](README_CN.md) [English](README.md)
|
||||
|
||||
# Claude Code Multi-Agent Workflow System
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://smithery.ai/skills?ns=cexll&utm_source=github&utm_medium=badge)
|
||||
|
||||
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://claude.ai/code)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
[](https://docs.claude.com/en/docs/claude-code/plugins)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
|
||||
> Enterprise-grade agile development automation with AI-powered multi-agent orchestration
|
||||
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini)
|
||||
|
||||
[中文文档](README_CN.md) | [Documentation](docs/)
|
||||
## Core Concept: Multi-Backend Architecture
|
||||
|
||||
## 🚀 Quick Start
|
||||
This system leverages a **dual-agent architecture** with pluggable AI backends:
|
||||
|
||||
### Installation
|
||||
| Role | Agent | Responsibility |
|
||||
|------|-------|----------------|
|
||||
| **Orchestrator** | Claude Code | Planning, context gathering, verification, user interaction |
|
||||
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini backends) |
|
||||
|
||||
**Plugin System (Recommended)**
|
||||
```bash
|
||||
/plugin github.com/cexll/myclaude
|
||||
```
|
||||
**Why this separation?**
|
||||
- Claude Code excels at understanding context and orchestrating complex workflows
|
||||
- Specialized backends (Codex for code, Claude for reasoning, Gemini for prototyping) excel at focused execution
|
||||
- Backend selection via `--backend codex|claude|gemini` matches the model to the task
|
||||
|
||||
## Quick Start(Please execute in Powershell on Windows)
|
||||
|
||||
**Traditional Installation**
|
||||
```bash
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
cd myclaude
|
||||
make install
|
||||
python3 install.py --install-dir ~/.claude
|
||||
```
|
||||
|
||||
### Basic Usage
|
||||
## Workflows Overview
|
||||
|
||||
### 0. OmO Multi-Agent Orchestrator (Recommended for Complex Tasks)
|
||||
|
||||
**Intelligent multi-agent orchestration that routes tasks to specialized agents based on risk signals.**
|
||||
|
||||
```bash
|
||||
# Full agile workflow
|
||||
/bmad-pilot "Build user authentication with OAuth2 and MFA"
|
||||
|
||||
# Lightweight development
|
||||
/requirements-pilot "Implement JWT token refresh"
|
||||
|
||||
# Direct development commands
|
||||
/code "Add API rate limiting"
|
||||
/omo "analyze and fix this authentication bug"
|
||||
```
|
||||
|
||||
## 📦 Plugin Modules
|
||||
**Agent Hierarchy:**
|
||||
| Agent | Role | Backend | Model |
|
||||
|-------|------|---------|-------|
|
||||
| `oracle` | Technical advisor | Claude | claude-opus-4-5 |
|
||||
| `librarian` | External research | Claude | claude-sonnet-4-5 |
|
||||
| `explore` | Codebase search | OpenCode | grok-code |
|
||||
| `develop` | Code implementation | Codex | gpt-5.2 |
|
||||
| `frontend-ui-ux-engineer` | UI/UX specialist | Gemini | gemini-3-pro |
|
||||
| `document-writer` | Documentation | Gemini | gemini-3-flash |
|
||||
|
||||
| Plugin | Description | Key Commands |
|
||||
|--------|-------------|--------------|
|
||||
| **[bmad-agile-workflow](docs/BMAD-WORKFLOW.md)** | Complete BMAD methodology with 6 specialized agents | `/bmad-pilot` |
|
||||
| **[requirements-driven-workflow](docs/REQUIREMENTS-WORKFLOW.md)** | Streamlined requirements-to-code workflow | `/requirements-pilot` |
|
||||
| **[development-essentials](docs/DEVELOPMENT-COMMANDS.md)** | Core development slash commands | `/code` `/debug` `/test` `/optimize` |
|
||||
| **[advanced-ai-agents](docs/ADVANCED-AGENTS.md)** | GPT-5 deep reasoning integration | Agent: `gpt5` |
|
||||
| **[requirements-clarity](docs/REQUIREMENTS-CLARITY.md)** | Automated requirements clarification with 100-point scoring | Auto-activated skill |
|
||||
**Routing Signals (Not Fixed Pipeline):**
|
||||
- Code location unclear → `explore`
|
||||
- External library/API → `librarian`
|
||||
- Risky/multi-file change → `oracle`
|
||||
- Implementation needed → `develop` / `frontend-ui-ux-engineer`
|
||||
|
||||
## 💡 Use Cases
|
||||
**Common Recipes:**
|
||||
- Explain code: `explore`
|
||||
- Small fix with known location: `develop` directly
|
||||
- Bug fix, location unknown: `explore → develop`
|
||||
- Cross-cutting refactor: `explore → oracle → develop`
|
||||
- External API integration: `explore + librarian → oracle → develop`
|
||||
|
||||
**BMAD Workflow** - Full agile process automation
|
||||
- Product requirements → Architecture design → Sprint planning → Development → Code review → QA testing
|
||||
- Quality gates with 90% thresholds
|
||||
- Automated document generation
|
||||
|
||||
**Requirements Workflow** - Fast prototyping
|
||||
- Requirements generation → Implementation → Review → Testing
|
||||
- Lightweight and practical
|
||||
|
||||
**Development Commands** - Daily coding
|
||||
- Direct implementation, debugging, testing, optimization
|
||||
- No workflow overhead
|
||||
|
||||
**Requirements Clarity** - Automated requirements engineering
|
||||
- Auto-detects vague requirements and initiates clarification
|
||||
- 100-point quality scoring system
|
||||
- Generates complete PRD documents
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
- **🤖 Role-Based Agents**: Specialized AI agents for each development phase
|
||||
- **📊 Quality Gates**: Automatic quality scoring with iterative refinement
|
||||
- **✅ Approval Points**: User confirmation at critical workflow stages
|
||||
- **📁 Persistent Artifacts**: All specs saved to `.claude/specs/`
|
||||
- **🔌 Plugin System**: Native Claude Code plugin support
|
||||
- **🔄 Flexible Workflows**: Choose full agile or lightweight development
|
||||
- **🎯 Requirements Clarity**: Automated requirements clarification with quality scoring
|
||||
|
||||
## 📚 Documentation
|
||||
|
||||
- **[BMAD Workflow Guide](docs/BMAD-WORKFLOW.md)** - Complete methodology and agent roles
|
||||
- **[Requirements Workflow](docs/REQUIREMENTS-WORKFLOW.md)** - Lightweight development process
|
||||
- **[Development Commands](docs/DEVELOPMENT-COMMANDS.md)** - Slash command reference
|
||||
- **[Plugin System](docs/PLUGIN-SYSTEM.md)** - Installation and configuration
|
||||
- **[Quick Start Guide](docs/QUICK-START.md)** - Get started in 5 minutes
|
||||
|
||||
## 🛠️ Installation Methods
|
||||
|
||||
**Method 1: Plugin Install** (One command)
|
||||
```bash
|
||||
/plugin install bmad-agile-workflow
|
||||
```
|
||||
|
||||
**Method 2: Make Commands** (Selective installation)
|
||||
```bash
|
||||
make deploy-bmad # BMAD workflow only
|
||||
make deploy-requirements # Requirements workflow only
|
||||
make deploy-all # Everything
|
||||
```
|
||||
|
||||
**Method 3: Manual Setup**
|
||||
- Copy `/commands/*.md` to `~/.config/claude/commands/`
|
||||
- Copy `/agents/*.md` to `~/.config/claude/agents/`
|
||||
|
||||
Run `make help` for all options.
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT License - see [LICENSE](LICENSE)
|
||||
|
||||
## 🙋 Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **Documentation**: [docs/](docs/)
|
||||
- **Plugin Guide**: [PLUGIN_README.md](PLUGIN_README.md)
|
||||
**Best For:** Complex bug investigation, multi-file refactoring, architecture decisions
|
||||
|
||||
---
|
||||
|
||||
**Transform your development with AI-powered automation** - One command, complete workflow, quality assured.
|
||||
### 1. Dev Workflow (Recommended)
|
||||
|
||||
**The primary workflow for most development tasks.**
|
||||
|
||||
```bash
|
||||
/dev "implement user authentication with JWT"
|
||||
```
|
||||
|
||||
**6-Step Process:**
|
||||
1. **Requirements Clarification** - Interactive Q&A to clarify scope
|
||||
2. **Codex Deep Analysis** - Codebase exploration and architecture decisions
|
||||
3. **Dev Plan Generation** - Structured task breakdown with test requirements
|
||||
4. **Parallel Execution** - Codex executes tasks concurrently
|
||||
5. **Coverage Validation** - Enforce ≥90% test coverage
|
||||
6. **Completion Summary** - Report with file changes and coverage stats
|
||||
|
||||
**Key Features:**
|
||||
- Claude Code orchestrates, Codex executes all code changes
|
||||
- Automatic task parallelization for speed
|
||||
- Mandatory 90% test coverage gate
|
||||
- Rollback on failure
|
||||
|
||||
**Best For:** Feature development, refactoring, bug fixes with tests
|
||||
|
||||
---
|
||||
|
||||
### 2. BMAD Agile Workflow
|
||||
|
||||
**Full enterprise agile methodology with 6 specialized agents.**
|
||||
|
||||
```bash
|
||||
/bmad-pilot "build e-commerce checkout system"
|
||||
```
|
||||
|
||||
**Agents:**
|
||||
| Agent | Role |
|
||||
|-------|------|
|
||||
| Product Owner | Requirements & user stories |
|
||||
| Architect | System design & tech decisions |
|
||||
| Tech Lead | Sprint planning & task breakdown |
|
||||
| Developer | Implementation |
|
||||
| Code Reviewer | Quality assurance |
|
||||
| QA Engineer | Testing & validation |
|
||||
|
||||
**Process:**
|
||||
```
|
||||
Requirements → Architecture → Sprint Plan → Development → Review → QA
|
||||
↓ ↓ ↓ ↓ ↓ ↓
|
||||
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
|
||||
```
|
||||
|
||||
**Best For:** Large features, team coordination, enterprise projects
|
||||
|
||||
---
|
||||
|
||||
### 3. Requirements-Driven Workflow
|
||||
|
||||
**Lightweight requirements-to-code pipeline.**
|
||||
|
||||
```bash
|
||||
/requirements-pilot "implement API rate limiting"
|
||||
```
|
||||
|
||||
**Process:**
|
||||
1. Requirements generation with quality scoring
|
||||
2. Implementation planning
|
||||
3. Code generation
|
||||
4. Review and testing
|
||||
|
||||
**Best For:** Quick prototypes, well-defined features
|
||||
|
||||
---
|
||||
|
||||
### 4. Development Essentials
|
||||
|
||||
**Direct commands for daily coding tasks.**
|
||||
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `/code` | Implement a feature |
|
||||
| `/debug` | Debug an issue |
|
||||
| `/test` | Write tests |
|
||||
| `/review` | Code review |
|
||||
| `/optimize` | Performance optimization |
|
||||
| `/refactor` | Code refactoring |
|
||||
| `/docs` | Documentation |
|
||||
|
||||
**Best For:** Quick tasks, no workflow overhead needed
|
||||
|
||||
## Enterprise Workflow Features
|
||||
|
||||
- **Multi-backend execution:** `codeagent-wrapper --backend codex|claude|gemini` (default `codex`) so you can match the model to the task without changing workflows.
|
||||
- **GitHub workflow commands:** `/gh-create-issue "short need"` creates structured issues; `/gh-issue-implement 123` pulls issue #123, drives development, and prepares the PR.
|
||||
- **Skills + hooks activation:** .claude/hooks run automation (tests, reviews), while `.claude/skills/skill-rules.json` auto-suggests the right skills. Keep hooks enabled in `.claude/settings.json` to activate the enterprise workflow helpers.
|
||||
|
||||
---
|
||||
|
||||
## Version Requirements
|
||||
|
||||
### Codex CLI
|
||||
**Minimum version:** Check compatibility with your installation
|
||||
|
||||
The codeagent-wrapper uses these Codex CLI features:
|
||||
- `codex e` - Execute commands (shorthand for `codex exec`)
|
||||
- `--skip-git-repo-check` - Skip git repository validation
|
||||
- `--json` - JSON stream output format
|
||||
- `-C <workdir>` - Set working directory
|
||||
- `resume <session_id>` - Resume previous sessions
|
||||
|
||||
**Verify Codex CLI is installed:**
|
||||
```bash
|
||||
which codex
|
||||
codex --version
|
||||
```
|
||||
|
||||
### Claude CLI
|
||||
**Minimum version:** Check compatibility with your installation
|
||||
|
||||
Required features:
|
||||
- `--output-format stream-json` - Streaming JSON output format
|
||||
- `--setting-sources` - Control setting sources (prevents infinite recursion)
|
||||
- `--dangerously-skip-permissions` - Skip permission prompts (use with caution)
|
||||
- `-p` - Prompt input flag
|
||||
- `-r <session_id>` - Resume sessions
|
||||
|
||||
**Security Note:** The wrapper adds `--dangerously-skip-permissions` for Claude by default. Set `CODEAGENT_SKIP_PERMISSIONS=false` to disable if you need permission prompts.
|
||||
|
||||
**Verify Claude CLI is installed:**
|
||||
```bash
|
||||
which claude
|
||||
claude --version
|
||||
```
|
||||
|
||||
### Gemini CLI
|
||||
**Minimum version:** Check compatibility with your installation
|
||||
|
||||
Required features:
|
||||
- `-o stream-json` - JSON stream output format
|
||||
- `-y` - Auto-approve prompts (non-interactive mode)
|
||||
- `-r <session_id>` - Resume sessions
|
||||
- `-p` - Prompt input flag
|
||||
|
||||
**Verify Gemini CLI is installed:**
|
||||
```bash
|
||||
which gemini
|
||||
gemini --version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
### Modular Installation (Recommended)
|
||||
|
||||
```bash
|
||||
# Install all enabled modules (dev + essentials by default)
|
||||
python3 install.py --install-dir ~/.claude
|
||||
|
||||
# Install specific module
|
||||
python3 install.py --module dev
|
||||
|
||||
# List available modules
|
||||
python3 install.py --list-modules
|
||||
|
||||
# Force overwrite existing files
|
||||
python3 install.py --force
|
||||
```
|
||||
|
||||
### Available Modules
|
||||
|
||||
| Module | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `dev` | ✓ Enabled | Dev workflow + Codex integration |
|
||||
| `essentials` | ✓ Enabled | Core development commands |
|
||||
| `bmad` | Disabled | Full BMAD agile workflow |
|
||||
| `requirements` | Disabled | Requirements-driven workflow |
|
||||
|
||||
### What Gets Installed
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── bin/
|
||||
│ └── codeagent-wrapper # Main executable
|
||||
├── CLAUDE.md # Core instructions and role definition
|
||||
├── commands/ # Slash commands (/dev, /code, etc.)
|
||||
├── agents/ # Agent definitions
|
||||
├── skills/
|
||||
│ └── codex/
|
||||
│ └── SKILL.md # Codex integration skill
|
||||
├── config.json # Configuration
|
||||
└── installed_modules.json # Installation status
|
||||
```
|
||||
|
||||
### Customizing Installation Directory
|
||||
|
||||
By default, myclaude installs to `~/.claude`. You can customize this using the `INSTALL_DIR` environment variable:
|
||||
|
||||
```bash
|
||||
# Install to custom directory
|
||||
INSTALL_DIR=/opt/myclaude bash install.sh
|
||||
|
||||
# Update your PATH accordingly
|
||||
export PATH="/opt/myclaude/bin:$PATH"
|
||||
```
|
||||
|
||||
**Directory Structure:**
|
||||
- `$INSTALL_DIR/bin/` - codeagent-wrapper binary
|
||||
- `$INSTALL_DIR/skills/` - Skill definitions
|
||||
- `$INSTALL_DIR/config.json` - Configuration file
|
||||
- `$INSTALL_DIR/commands/` - Slash command definitions
|
||||
- `$INSTALL_DIR/agents/` - Agent definitions
|
||||
|
||||
**Note:** When using a custom installation directory, ensure that `$INSTALL_DIR/bin` is added to your `PATH` environment variable.
|
||||
|
||||
### Configuration
|
||||
|
||||
Edit `config.json` to customize:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"modules": {
|
||||
"dev": {
|
||||
"enabled": true,
|
||||
"operations": [
|
||||
{"type": "merge_dir", "source": "dev-workflow"},
|
||||
{"type": "copy_file", "source": "memorys/CLAUDE.md", "target": "CLAUDE.md"},
|
||||
{"type": "copy_file", "source": "skills/codex/SKILL.md", "target": "skills/codex/SKILL.md"},
|
||||
{"type": "run_command", "command": "bash install.sh"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Operation Types:**
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `merge_dir` | Merge subdirs (commands/, agents/) into install dir |
|
||||
| `copy_dir` | Copy entire directory |
|
||||
| `copy_file` | Copy single file to target path |
|
||||
| `run_command` | Execute shell command |
|
||||
|
||||
---
|
||||
|
||||
## Codex Integration
|
||||
|
||||
The `codex` skill enables Claude Code to delegate code execution to Codex CLI.
|
||||
|
||||
### Usage in Workflows
|
||||
|
||||
```bash
|
||||
# Codex is invoked via the skill
|
||||
codeagent-wrapper - <<'EOF'
|
||||
implement @src/auth.ts with JWT validation
|
||||
EOF
|
||||
```
|
||||
|
||||
### Parallel Execution
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: backend_api
|
||||
workdir: /project/backend
|
||||
---CONTENT---
|
||||
implement REST endpoints for /api/users
|
||||
|
||||
---TASK---
|
||||
id: frontend_ui
|
||||
workdir: /project/frontend
|
||||
dependencies: backend_api
|
||||
---CONTENT---
|
||||
create React components consuming the API
|
||||
EOF
|
||||
```
|
||||
|
||||
### Install Codex Wrapper
|
||||
|
||||
```bash
|
||||
# Automatic (via dev module)
|
||||
python3 install.py --module dev
|
||||
|
||||
# Manual
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
#### Windows
|
||||
|
||||
Windows installs place `codeagent-wrapper.exe` in `%USERPROFILE%\bin`.
|
||||
|
||||
```powershell
|
||||
# PowerShell (recommended)
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1
|
||||
|
||||
# Batch (cmd)
|
||||
install.bat
|
||||
```
|
||||
|
||||
**Add to PATH** (if installer doesn't detect it):
|
||||
|
||||
```powershell
|
||||
# PowerShell - persistent for current user
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$HOME\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
|
||||
|
||||
# PowerShell - current session only
|
||||
$Env:PATH = "$HOME\bin;$Env:PATH"
|
||||
```
|
||||
|
||||
```batch
|
||||
REM cmd.exe - persistent for current user (use PowerShell method above instead)
|
||||
REM WARNING: This expands %PATH% which includes system PATH, causing duplication
|
||||
REM Note: Using reg add instead of setx to avoid 1024-character truncation limit
|
||||
reg add "HKCU\Environment" /v Path /t REG_EXPAND_SZ /d "%USERPROFILE%\bin;%PATH%" /f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Workflow Selection Guide
|
||||
|
||||
| Scenario | Recommended Workflow |
|
||||
|----------|---------------------|
|
||||
| New feature with tests | `/dev` |
|
||||
| Quick bug fix | `/debug` or `/code` |
|
||||
| Large multi-sprint feature | `/bmad-pilot` |
|
||||
| Prototype or POC | `/requirements-pilot` |
|
||||
| Code review | `/review` |
|
||||
| Performance issue | `/optimize` |
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Codex wrapper not found:**
|
||||
```bash
|
||||
# Installer auto-adds PATH, check if configured
|
||||
if [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]]; then
|
||||
echo "PATH not configured. Reinstalling..."
|
||||
bash install.sh
|
||||
fi
|
||||
|
||||
# Or manually add (idempotent command)
|
||||
[[ ":$PATH:" != *":$HOME/.claude/bin:"* ]] && echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc
|
||||
```
|
||||
|
||||
**Permission denied:**
|
||||
```bash
|
||||
python3 install.py --install-dir ~/.claude --force
|
||||
```
|
||||
|
||||
**Module not loading:**
|
||||
```bash
|
||||
# Check installation status
|
||||
cat ~/.claude/installed_modules.json
|
||||
|
||||
# Reinstall specific module
|
||||
python3 install.py --module dev --force
|
||||
```
|
||||
|
||||
### Version Compatibility Issues
|
||||
|
||||
**Backend CLI not found:**
|
||||
```bash
|
||||
# Check if backend CLIs are installed
|
||||
which codex
|
||||
which claude
|
||||
which gemini
|
||||
|
||||
# Install missing backends
|
||||
# Codex: Follow installation instructions at https://codex.docs
|
||||
# Claude: Follow installation instructions at https://claude.ai/docs
|
||||
# Gemini: Follow installation instructions at https://ai.google.dev/docs
|
||||
```
|
||||
|
||||
**Unsupported CLI flags:**
|
||||
```bash
|
||||
# If you see errors like "unknown flag" or "invalid option"
|
||||
|
||||
# Check backend CLI version
|
||||
codex --version
|
||||
claude --version
|
||||
gemini --version
|
||||
|
||||
# For Codex: Ensure it supports `e`, `--skip-git-repo-check`, `--json`, `-C`, and `resume`
|
||||
# For Claude: Ensure it supports `--output-format stream-json`, `--setting-sources`, `-r`
|
||||
# For Gemini: Ensure it supports `-o stream-json`, `-y`, `-r`, `-p`
|
||||
|
||||
# Update your backend CLI to the latest version if needed
|
||||
```
|
||||
|
||||
**JSON parsing errors:**
|
||||
```bash
|
||||
# If you see "failed to parse JSON output" errors
|
||||
|
||||
# Verify the backend outputs stream-json format
|
||||
codex e --json "test task" # Should output newline-delimited JSON
|
||||
claude --output-format stream-json -p "test" # Should output stream JSON
|
||||
|
||||
# If not, your backend CLI version may be too old or incompatible
|
||||
```
|
||||
|
||||
**Infinite recursion with Claude backend:**
|
||||
```bash
|
||||
# The wrapper prevents this with `--setting-sources ""` flag
|
||||
# If you still see recursion, ensure your Claude CLI supports this flag
|
||||
|
||||
claude --help | grep "setting-sources"
|
||||
|
||||
# If flag is not supported, upgrade Claude CLI
|
||||
```
|
||||
|
||||
**Session resume failures:**
|
||||
```bash
|
||||
# Check if session ID is valid
|
||||
codex history # List recent sessions
|
||||
claude history
|
||||
|
||||
# Ensure backend CLI supports session resumption
|
||||
codex resume <session_id> "test" # Should continue from previous session
|
||||
claude -r <session_id> "test"
|
||||
|
||||
# If not supported, use new sessions instead of resume mode
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ (Frequently Asked Questions)
|
||||
|
||||
### Q1: `codeagent-wrapper` execution fails with "Unknown event format"
|
||||
|
||||
**Problem:**
|
||||
```
|
||||
Unknown event format: {"type":"turn.started"}
|
||||
Unknown event format: {"type":"assistant", ...}
|
||||
```
|
||||
|
||||
**Solution:**
|
||||
This is a logging event format display issue and does not affect actual functionality. It will be fixed in the next version. You can ignore these log outputs.
|
||||
|
||||
**Related Issue:** [#96](https://github.com/cexll/myclaude/issues/96)
|
||||
|
||||
---
|
||||
|
||||
### Q2: Gemini cannot read files ignored by `.gitignore`
|
||||
|
||||
**Problem:**
|
||||
When using `codeagent-wrapper --backend gemini`, files in directories like `.claude/` that are ignored by `.gitignore` cannot be read.
|
||||
|
||||
**Solution:**
|
||||
- **Option 1:** Remove `.claude/` from your `.gitignore` file
|
||||
- **Option 2:** Ensure files that need to be read are not in `.gitignore` list
|
||||
|
||||
**Related Issue:** [#75](https://github.com/cexll/myclaude/issues/75)
|
||||
|
||||
---
|
||||
|
||||
### Q3: `/dev` command parallel execution is very slow
|
||||
|
||||
**Problem:**
|
||||
Using `/dev` command for simple features takes too long (over 30 minutes) with no visibility into task progress.
|
||||
|
||||
**Solution:**
|
||||
1. **Check logs:** Review `C:\Users\User\AppData\Local\Temp\codeagent-wrapper-*.log` to identify bottlenecks
|
||||
2. **Adjust backend:**
|
||||
- Try faster models like `gpt-5.1-codex-max`
|
||||
- Running in WSL may be significantly faster
|
||||
3. **Workspace:** Use a single repository instead of monorepo with multiple sub-projects
|
||||
|
||||
**Related Issue:** [#77](https://github.com/cexll/myclaude/issues/77)
|
||||
|
||||
---
|
||||
|
||||
### Q4: Codex permission denied with new Go version
|
||||
|
||||
**Problem:**
|
||||
After upgrading to the new Go-based Codex implementation, execution fails with permission denied errors.
|
||||
|
||||
**Solution:**
|
||||
Add the following configuration to `~/.codex/config.yaml` (Windows: `c:\user\.codex\config.toml`):
|
||||
```yaml
|
||||
model = "gpt-5.1-codex-max"
|
||||
model_reasoning_effort = "high"
|
||||
model_reasoning_summary = "detailed"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "workspace-write"
|
||||
disable_response_storage = true
|
||||
network_access = true
|
||||
```
|
||||
|
||||
**Key settings:**
|
||||
- `approval_policy = "never"` - Remove approval restrictions
|
||||
- `sandbox_mode = "workspace-write"` - Allow workspace write access
|
||||
- `network_access = true` - Enable network access
|
||||
|
||||
**Related Issue:** [#31](https://github.com/cexll/myclaude/issues/31)
|
||||
|
||||
---
|
||||
|
||||
### Q5: How to disable default bypass/skip-permissions mode
|
||||
|
||||
**Background:**
|
||||
By default, codeagent-wrapper enables bypass mode for both Codex and Claude backends:
|
||||
- `CODEX_BYPASS_SANDBOX=true` - Bypasses Codex sandbox restrictions
|
||||
- `CODEAGENT_SKIP_PERMISSIONS=true` - Skips Claude permission prompts
|
||||
|
||||
**To disable (if you need sandbox/permission protection):**
|
||||
```bash
|
||||
export CODEX_BYPASS_SANDBOX=false
|
||||
export CODEAGENT_SKIP_PERMISSIONS=false
|
||||
```
|
||||
|
||||
Or add to your shell profile (`~/.zshrc` or `~/.bashrc`):
|
||||
```bash
|
||||
echo 'export CODEX_BYPASS_SANDBOX=false' >> ~/.zshrc
|
||||
echo 'export CODEAGENT_SKIP_PERMISSIONS=false' >> ~/.zshrc
|
||||
```
|
||||
|
||||
**Note:** Disabling bypass mode will require manual approval for certain operations.
|
||||
|
||||
---
|
||||
|
||||
**Still having issues?** Visit [GitHub Issues](https://github.com/cexll/myclaude/issues) to search or report new issues.
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
- **[Codeagent-Wrapper Guide](docs/CODEAGENT-WRAPPER.md)** - Multi-backend execution wrapper
|
||||
- **[Hooks Documentation](docs/HOOKS.md)** - Custom hooks and automation
|
||||
|
||||
### Additional Resources
|
||||
- **[Installation Log](install.log)** - Installation history and troubleshooting
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 License - see [LICENSE](LICENSE)
|
||||
|
||||
## Support
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **Documentation**: [docs/](docs/)
|
||||
|
||||
---
|
||||
|
||||
**Claude Code + Codex = Better Development** - Orchestration meets execution.
|
||||
|
||||
556
README_CN.md
556
README_CN.md
@@ -1,121 +1,483 @@
|
||||
# Claude Code 多智能体工作流系统
|
||||
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
[](https://claude.ai/code)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
[](https://docs.claude.com/en/docs/claude-code/plugins)
|
||||
[](https://github.com/cexll/myclaude)
|
||||
|
||||
> 企业级敏捷开发自动化与 AI 驱动的多智能体编排
|
||||
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini)
|
||||
|
||||
[English](README.md) | [文档](docs/)
|
||||
## 核心概念:多后端架构
|
||||
|
||||
## 🚀 快速开始
|
||||
本系统采用**双智能体架构**与可插拔 AI 后端:
|
||||
|
||||
### 安装
|
||||
| 角色 | 智能体 | 职责 |
|
||||
|------|-------|------|
|
||||
| **编排者** | Claude Code | 规划、上下文收集、验证、用户交互 |
|
||||
| **执行者** | codeagent-wrapper | 代码编辑、测试执行(Codex/Claude/Gemini 后端)|
|
||||
|
||||
**插件系统(推荐)**
|
||||
```bash
|
||||
/plugin github.com/cexll/myclaude
|
||||
```
|
||||
**为什么分离?**
|
||||
- Claude Code 擅长理解上下文和编排复杂工作流
|
||||
- 专业后端(Codex 擅长代码、Claude 擅长推理、Gemini 擅长原型)专注执行
|
||||
- 通过 `--backend codex|claude|gemini` 匹配模型与任务
|
||||
|
||||
## 快速开始(windows上请在Powershell中执行)
|
||||
|
||||
**传统安装**
|
||||
```bash
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
cd myclaude
|
||||
make install
|
||||
python3 install.py --install-dir ~/.claude
|
||||
```
|
||||
|
||||
### 基本使用
|
||||
## 工作流概览
|
||||
|
||||
### 0. OmO 多智能体编排器(复杂任务推荐)
|
||||
|
||||
**基于风险信号智能路由任务到专业智能体的多智能体编排系统。**
|
||||
|
||||
```bash
|
||||
# 完整敏捷工作流
|
||||
/bmad-pilot "构建用户认证系统,支持 OAuth2 和多因素认证"
|
||||
|
||||
# 轻量级开发
|
||||
/requirements-pilot "实现 JWT 令牌刷新"
|
||||
|
||||
# 直接开发命令
|
||||
/code "添加 API 限流功能"
|
||||
/omo "分析并修复这个认证 bug"
|
||||
```
|
||||
|
||||
## 📦 插件模块
|
||||
**智能体层级:**
|
||||
| 智能体 | 角色 | 后端 | 模型 |
|
||||
|-------|------|------|------|
|
||||
| `oracle` | 技术顾问 | Claude | claude-opus-4-5 |
|
||||
| `librarian` | 外部研究 | Claude | claude-sonnet-4-5 |
|
||||
| `explore` | 代码库搜索 | OpenCode | grok-code |
|
||||
| `develop` | 代码实现 | Codex | gpt-5.2 |
|
||||
| `frontend-ui-ux-engineer` | UI/UX 专家 | Gemini | gemini-3-pro |
|
||||
| `document-writer` | 文档撰写 | Gemini | gemini-3-flash |
|
||||
|
||||
| 插件 | 描述 | 主要命令 |
|
||||
|------|------|---------|
|
||||
| **[bmad-agile-workflow](docs/BMAD-WORKFLOW.md)** | 完整 BMAD 方法论,包含6个专业智能体 | `/bmad-pilot` |
|
||||
| **[requirements-driven-workflow](docs/REQUIREMENTS-WORKFLOW.md)** | 精简的需求到代码工作流 | `/requirements-pilot` |
|
||||
| **[development-essentials](docs/DEVELOPMENT-COMMANDS.md)** | 核心开发斜杠命令 | `/code` `/debug` `/test` `/optimize` |
|
||||
| **[advanced-ai-agents](docs/ADVANCED-AGENTS.md)** | GPT-5 深度推理集成 | 智能体: `gpt5` |
|
||||
| **[requirements-clarity](docs/REQUIREMENTS-CLARITY.md)** | 自动需求澄清,100分制质量评分 | 自动激活技能 |
|
||||
**路由信号(非固定流水线):**
|
||||
- 代码位置不明确 → `explore`
|
||||
- 外部库/API → `librarian`
|
||||
- 高风险/多文件变更 → `oracle`
|
||||
- 需要实现 → `develop` / `frontend-ui-ux-engineer`
|
||||
|
||||
## 💡 使用场景
|
||||
**常用配方:**
|
||||
- 解释代码:`explore`
|
||||
- 位置已知的小修复:直接 `develop`
|
||||
- Bug 修复,位置未知:`explore → develop`
|
||||
- 跨模块重构:`explore → oracle → develop`
|
||||
- 外部 API 集成:`explore + librarian → oracle → develop`
|
||||
|
||||
**BMAD 工作流** - 完整敏捷流程自动化
|
||||
- 产品需求 → 架构设计 → 冲刺规划 → 开发实现 → 代码审查 → 质量测试
|
||||
- 90% 阈值质量门控
|
||||
- 自动生成文档
|
||||
|
||||
**Requirements 工作流** - 快速原型开发
|
||||
- 需求生成 → 实现 → 审查 → 测试
|
||||
- 轻量级实用主义
|
||||
|
||||
**开发命令** - 日常编码
|
||||
- 直接实现、调试、测试、优化
|
||||
- 无工作流开销
|
||||
|
||||
**需求澄清** - 自动化需求工程
|
||||
- 自动检测模糊需求并启动澄清流程
|
||||
- 100分制质量评分系统
|
||||
- 生成完整的产品需求文档
|
||||
|
||||
## 🎯 核心特性
|
||||
|
||||
- **🤖 角色化智能体**: 每个开发阶段的专业 AI 智能体
|
||||
- **📊 质量门控**: 自动质量评分,迭代优化
|
||||
- **✅ 确认节点**: 关键工作流阶段的用户确认
|
||||
- **📁 持久化产物**: 所有规格保存至 `.claude/specs/`
|
||||
- **🔌 插件系统**: 原生 Claude Code 插件支持
|
||||
- **🔄 灵活工作流**: 选择完整敏捷或轻量开发
|
||||
- **🎯 需求澄清**: 自动化需求澄清与质量评分
|
||||
|
||||
## 📚 文档
|
||||
|
||||
- **[BMAD 工作流指南](docs/BMAD-WORKFLOW.md)** - 完整方法论和智能体角色
|
||||
- **[Requirements 工作流](docs/REQUIREMENTS-WORKFLOW.md)** - 轻量级开发流程
|
||||
- **[开发命令参考](docs/DEVELOPMENT-COMMANDS.md)** - 斜杠命令说明
|
||||
- **[插件系统](docs/PLUGIN-SYSTEM.md)** - 安装与配置
|
||||
- **[快速上手](docs/QUICK-START.md)** - 5分钟入门
|
||||
|
||||
## 🛠️ 安装方式
|
||||
|
||||
**方式1: 插件安装**(一条命令)
|
||||
```bash
|
||||
/plugin install bmad-agile-workflow
|
||||
```
|
||||
|
||||
**方式2: Make 命令**(选择性安装)
|
||||
```bash
|
||||
make deploy-bmad # 仅 BMAD 工作流
|
||||
make deploy-requirements # 仅 Requirements 工作流
|
||||
make deploy-all # 全部安装
|
||||
```
|
||||
|
||||
**方式3: 手动安装**
|
||||
- 复制 `/commands/*.md` 到 `~/.config/claude/commands/`
|
||||
- 复制 `/agents/*.md` 到 `~/.config/claude/agents/`
|
||||
|
||||
运行 `make help` 查看所有选项。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
MIT 许可证 - 查看 [LICENSE](LICENSE)
|
||||
|
||||
## 🙋 支持
|
||||
|
||||
- **问题反馈**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **文档**: [docs/](docs/)
|
||||
- **插件指南**: [PLUGIN_README.md](PLUGIN_README.md)
|
||||
**适用场景:** 复杂 bug 调查、多文件重构、架构决策
|
||||
|
||||
---
|
||||
|
||||
**使用 AI 驱动的自动化转型您的开发流程** - 一条命令,完整工作流,质量保证。
|
||||
### 1. Dev 工作流(推荐)
|
||||
|
||||
**大多数开发任务的首选工作流。**
|
||||
|
||||
```bash
|
||||
/dev "实现 JWT 用户认证"
|
||||
```
|
||||
|
||||
**6 步流程:**
|
||||
1. **需求澄清** - 交互式问答明确范围
|
||||
2. **Codex 深度分析** - 代码库探索和架构决策
|
||||
3. **开发计划生成** - 结构化任务分解和测试要求
|
||||
4. **并行执行** - Codex 并发执行任务
|
||||
5. **覆盖率验证** - 强制 ≥90% 测试覆盖率
|
||||
6. **完成总结** - 文件变更和覆盖率报告
|
||||
|
||||
**核心特性:**
|
||||
- Claude Code 编排,Codex 执行所有代码变更
|
||||
- 自动任务并行化提升速度
|
||||
- 强制 90% 测试覆盖率门禁
|
||||
- 失败自动回滚
|
||||
|
||||
**适用场景:** 功能开发、重构、带测试的 bug 修复
|
||||
|
||||
---
|
||||
|
||||
### 2. BMAD 敏捷工作流
|
||||
|
||||
**包含 6 个专业智能体的完整企业敏捷方法论。**
|
||||
|
||||
```bash
|
||||
/bmad-pilot "构建电商结账系统"
|
||||
```
|
||||
|
||||
**智能体角色:**
|
||||
| 智能体 | 职责 |
|
||||
|-------|------|
|
||||
| Product Owner | 需求与用户故事 |
|
||||
| Architect | 系统设计与技术决策 |
|
||||
| Tech Lead | Sprint 规划与任务分解 |
|
||||
| Developer | 实现 |
|
||||
| Code Reviewer | 质量保证 |
|
||||
| QA Engineer | 测试与验证 |
|
||||
|
||||
**流程:**
|
||||
```
|
||||
需求 → 架构 → Sprint计划 → 开发 → 审查 → QA
|
||||
↓ ↓ ↓ ↓ ↓ ↓
|
||||
PRD.md DESIGN.md SPRINT.md Code REVIEW.md TEST.md
|
||||
```
|
||||
|
||||
**适用场景:** 大型功能、团队协作、企业项目
|
||||
|
||||
---
|
||||
|
||||
### 3. 需求驱动工作流
|
||||
|
||||
**轻量级需求到代码流水线。**
|
||||
|
||||
```bash
|
||||
/requirements-pilot "实现 API 限流"
|
||||
```
|
||||
|
||||
**流程:**
|
||||
1. 带质量评分的需求生成
|
||||
2. 实现规划
|
||||
3. 代码生成
|
||||
4. 审查和测试
|
||||
|
||||
**适用场景:** 快速原型、明确定义的功能
|
||||
|
||||
---
|
||||
|
||||
### 4. 开发基础命令
|
||||
|
||||
**日常编码任务的直接命令。**
|
||||
|
||||
| 命令 | 用途 |
|
||||
|------|------|
|
||||
| `/code` | 实现功能 |
|
||||
| `/debug` | 调试问题 |
|
||||
| `/test` | 编写测试 |
|
||||
| `/review` | 代码审查 |
|
||||
| `/optimize` | 性能优化 |
|
||||
| `/refactor` | 代码重构 |
|
||||
| `/docs` | 编写文档 |
|
||||
|
||||
**适用场景:** 快速任务,无需工作流开销
|
||||
|
||||
---
|
||||
|
||||
## 安装
|
||||
|
||||
### 模块化安装(推荐)
|
||||
|
||||
```bash
|
||||
# 安装所有启用的模块(默认:dev + essentials)
|
||||
python3 install.py --install-dir ~/.claude
|
||||
|
||||
# 安装特定模块
|
||||
python3 install.py --module dev
|
||||
|
||||
# 列出可用模块
|
||||
python3 install.py --list-modules
|
||||
|
||||
# 强制覆盖现有文件
|
||||
python3 install.py --force
|
||||
```
|
||||
|
||||
### 可用模块
|
||||
|
||||
| 模块 | 默认 | 描述 |
|
||||
|------|------|------|
|
||||
| `dev` | ✓ 启用 | Dev 工作流 + Codex 集成 |
|
||||
| `essentials` | ✓ 启用 | 核心开发命令 |
|
||||
| `bmad` | 禁用 | 完整 BMAD 敏捷工作流 |
|
||||
| `requirements` | 禁用 | 需求驱动工作流 |
|
||||
|
||||
### 安装内容
|
||||
|
||||
```
|
||||
~/.claude/
|
||||
├── bin/
|
||||
│ └── codeagent-wrapper # 主可执行文件
|
||||
├── CLAUDE.md # 核心指令和角色定义
|
||||
├── commands/ # 斜杠命令 (/dev, /code 等)
|
||||
├── agents/ # 智能体定义
|
||||
├── skills/
|
||||
│ └── codex/
|
||||
│ └── SKILL.md # Codex 集成技能
|
||||
├── config.json # 配置文件
|
||||
└── installed_modules.json # 安装状态
|
||||
```
|
||||
|
||||
### 自定义安装目录
|
||||
|
||||
默认情况下,myclaude 安装到 `~/.claude`。您可以使用 `INSTALL_DIR` 环境变量自定义安装目录:
|
||||
|
||||
```bash
|
||||
# 安装到自定义目录
|
||||
INSTALL_DIR=/opt/myclaude bash install.sh
|
||||
|
||||
# 相应更新您的 PATH
|
||||
export PATH="/opt/myclaude/bin:$PATH"
|
||||
```
|
||||
|
||||
**目录结构:**
|
||||
- `$INSTALL_DIR/bin/` - codeagent-wrapper 可执行文件
|
||||
- `$INSTALL_DIR/skills/` - 技能定义
|
||||
- `$INSTALL_DIR/config.json` - 配置文件
|
||||
- `$INSTALL_DIR/commands/` - 斜杠命令定义
|
||||
- `$INSTALL_DIR/agents/` - 智能体定义
|
||||
|
||||
**注意:** 使用自定义安装目录时,请确保将 `$INSTALL_DIR/bin` 添加到您的 `PATH` 环境变量中。
|
||||
|
||||
### 配置
|
||||
|
||||
编辑 `config.json` 自定义:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"modules": {
|
||||
"dev": {
|
||||
"enabled": true,
|
||||
"operations": [
|
||||
{"type": "merge_dir", "source": "dev-workflow"},
|
||||
{"type": "copy_file", "source": "memorys/CLAUDE.md", "target": "CLAUDE.md"},
|
||||
{"type": "copy_file", "source": "skills/codex/SKILL.md", "target": "skills/codex/SKILL.md"},
|
||||
{"type": "run_command", "command": "bash install.sh"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**操作类型:**
|
||||
| 类型 | 描述 |
|
||||
|------|------|
|
||||
| `merge_dir` | 合并子目录 (commands/, agents/) 到安装目录 |
|
||||
| `copy_dir` | 复制整个目录 |
|
||||
| `copy_file` | 复制单个文件到目标路径 |
|
||||
| `run_command` | 执行 shell 命令 |
|
||||
|
||||
---
|
||||
|
||||
## Codex 集成
|
||||
|
||||
`codex` 技能使 Claude Code 能够将代码执行委托给 Codex CLI。
|
||||
|
||||
### 工作流中的使用
|
||||
|
||||
```bash
|
||||
# 通过技能调用 Codex
|
||||
codeagent-wrapper - <<'EOF'
|
||||
在 @src/auth.ts 中实现 JWT 验证
|
||||
EOF
|
||||
```
|
||||
|
||||
### 并行执行
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: backend_api
|
||||
workdir: /project/backend
|
||||
---CONTENT---
|
||||
实现 /api/users 的 REST 端点
|
||||
|
||||
---TASK---
|
||||
id: frontend_ui
|
||||
workdir: /project/frontend
|
||||
dependencies: backend_api
|
||||
---CONTENT---
|
||||
创建消费 API 的 React 组件
|
||||
EOF
|
||||
```
|
||||
|
||||
### 安装 Codex Wrapper
|
||||
|
||||
```bash
|
||||
# 自动(通过 dev 模块)
|
||||
python3 install.py --module dev
|
||||
|
||||
# 手动
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
#### Windows 系统
|
||||
|
||||
Windows 系统会将 `codeagent-wrapper.exe` 安装到 `%USERPROFILE%\bin`。
|
||||
|
||||
```powershell
|
||||
# PowerShell(推荐)
|
||||
powershell -ExecutionPolicy Bypass -File install.ps1
|
||||
|
||||
# 批处理(cmd)
|
||||
install.bat
|
||||
```
|
||||
|
||||
**添加到 PATH**(如果安装程序未自动检测):
|
||||
|
||||
```powershell
|
||||
# PowerShell - 永久添加(当前用户)
|
||||
[Environment]::SetEnvironmentVariable('PATH', "$HOME\bin;" + [Environment]::GetEnvironmentVariable('PATH','User'), 'User')
|
||||
|
||||
# PowerShell - 仅当前会话
|
||||
$Env:PATH = "$HOME\bin;$Env:PATH"
|
||||
```
|
||||
|
||||
```batch
|
||||
REM cmd.exe - 永久添加(当前用户)(建议使用上面的 PowerShell 方法)
|
||||
REM 警告:此命令会展开 %PATH% 包含系统 PATH,导致重复
|
||||
REM 注意:使用 reg add 而非 setx 以避免 1024 字符截断限制
|
||||
reg add "HKCU\Environment" /v Path /t REG_EXPAND_SZ /d "%USERPROFILE%\bin;%PATH%" /f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 工作流选择指南
|
||||
|
||||
| 场景 | 推荐工作流 |
|
||||
|------|----------|
|
||||
| 带测试的新功能 | `/dev` |
|
||||
| 快速 bug 修复 | `/debug` 或 `/code` |
|
||||
| 大型多 Sprint 功能 | `/bmad-pilot` |
|
||||
| 原型或 POC | `/requirements-pilot` |
|
||||
| 代码审查 | `/review` |
|
||||
| 性能问题 | `/optimize` |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 常见问题
|
||||
|
||||
**Codex wrapper 未找到:**
|
||||
```bash
|
||||
# 安装程序会自动添加 PATH,检查是否已添加
|
||||
if [[ ":$PATH:" != *":$HOME/.claude/bin:"* ]]; then
|
||||
echo "PATH not configured. Reinstalling..."
|
||||
bash install.sh
|
||||
fi
|
||||
|
||||
# 或手动添加(幂等性命令)
|
||||
[[ ":$PATH:" != *":$HOME/.claude/bin:"* ]] && echo 'export PATH="$HOME/.claude/bin:$PATH"' >> ~/.zshrc
|
||||
```
|
||||
|
||||
**权限被拒绝:**
|
||||
```bash
|
||||
python3 install.py --install-dir ~/.claude --force
|
||||
```
|
||||
|
||||
**模块未加载:**
|
||||
```bash
|
||||
# 检查安装状态
|
||||
cat ~/.claude/installed_modules.json
|
||||
|
||||
# 重新安装特定模块
|
||||
python3 install.py --module dev --force
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题 (FAQ)
|
||||
|
||||
### Q1: `codeagent-wrapper` 执行时报错 "Unknown event format"
|
||||
|
||||
**问题描述:**
|
||||
执行 `codeagent-wrapper` 时出现错误:
|
||||
```
|
||||
Unknown event format: {"type":"turn.started"}
|
||||
Unknown event format: {"type":"assistant", ...}
|
||||
```
|
||||
|
||||
**解决方案:**
|
||||
这是日志事件流的显示问题,不影响实际功能执行。预计在下个版本中修复。如需排查其他问题,可忽略此日志输出。
|
||||
|
||||
**相关 Issue:** [#96](https://github.com/cexll/myclaude/issues/96)
|
||||
|
||||
---
|
||||
|
||||
### Q2: Gemini 无法读取 `.gitignore` 忽略的文件
|
||||
|
||||
**问题描述:**
|
||||
使用 `codeagent-wrapper --backend gemini` 时,无法读取 `.claude/` 等被 `.gitignore` 忽略的目录中的文件。
|
||||
|
||||
**解决方案:**
|
||||
- **方案一:** 在项目根目录的 `.gitignore` 中取消对 `.claude/` 的忽略
|
||||
- **方案二:** 确保需要读取的文件不在 `.gitignore` 忽略列表中
|
||||
|
||||
**相关 Issue:** [#75](https://github.com/cexll/myclaude/issues/75)
|
||||
|
||||
---
|
||||
|
||||
### Q3: `/dev` 命令并行执行特别慢
|
||||
|
||||
**问题描述:**
|
||||
使用 `/dev` 命令开发简单功能耗时过长(超过30分钟),无法了解任务执行状态。
|
||||
|
||||
**解决方案:**
|
||||
1. **检查日志:** 查看 `C:\Users\User\AppData\Local\Temp\codeagent-wrapper-*.log` 分析瓶颈
|
||||
2. **调整后端:**
|
||||
- 尝试使用 `gpt-5.1-codex-max` 等更快的模型
|
||||
- 在 WSL 环境下运行速度可能更快
|
||||
3. **工作区选择:** 使用独立的代码仓库而非包含多个子项目的 monorepo
|
||||
|
||||
**相关 Issue:** [#77](https://github.com/cexll/myclaude/issues/77)
|
||||
|
||||
---
|
||||
|
||||
### Q4: 新版 Go 实现的 Codex 权限不足
|
||||
|
||||
**问题描述:**
|
||||
升级到新版 Go 实现的 Codex 后,出现权限不足的错误。
|
||||
|
||||
**解决方案:**
|
||||
在 `~/.codex/config.yaml` 中添加以下配置(Windows: `c:\user\.codex\config.toml`):
|
||||
```yaml
|
||||
model = "gpt-5.1-codex-max"
|
||||
model_reasoning_effort = "high"
|
||||
model_reasoning_summary = "detailed"
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "workspace-write"
|
||||
disable_response_storage = true
|
||||
network_access = true
|
||||
```
|
||||
|
||||
**关键配置说明:**
|
||||
- `approval_policy = "never"` - 移除审批限制
|
||||
- `sandbox_mode = "workspace-write"` - 允许工作区写入权限
|
||||
- `network_access = true` - 启用网络访问
|
||||
|
||||
**相关 Issue:** [#31](https://github.com/cexll/myclaude/issues/31)
|
||||
|
||||
---
|
||||
|
||||
### Q5: 执行时遇到权限拒绝或沙箱限制
|
||||
|
||||
**问题描述:**
|
||||
运行 codeagent-wrapper 时出现权限错误或沙箱限制。
|
||||
|
||||
**解决方案:**
|
||||
设置以下环境变量:
|
||||
```bash
|
||||
export CODEX_BYPASS_SANDBOX=true
|
||||
export CODEAGENT_SKIP_PERMISSIONS=true
|
||||
```
|
||||
|
||||
或添加到 shell 配置文件(`~/.zshrc` 或 `~/.bashrc`):
|
||||
```bash
|
||||
echo 'export CODEX_BYPASS_SANDBOX=true' >> ~/.zshrc
|
||||
echo 'export CODEAGENT_SKIP_PERMISSIONS=true' >> ~/.zshrc
|
||||
```
|
||||
|
||||
**注意:** 这些设置会绕过安全限制,请仅在可信环境中使用。
|
||||
|
||||
---
|
||||
|
||||
**仍有疑问?** 请访问 [GitHub Issues](https://github.com/cexll/myclaude/issues) 搜索或提交新问题。
|
||||
|
||||
---
|
||||
|
||||
## 许可证
|
||||
|
||||
AGPL-3.0 License - 查看 [LICENSE](LICENSE)
|
||||
|
||||
## 支持
|
||||
|
||||
- **问题反馈**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||
- **文档**: [docs/](docs/)
|
||||
|
||||
---
|
||||
|
||||
**Claude Code + Codex = 更好的开发** - 编排遇见执行。
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "advanced-ai-agents",
|
||||
"source": "./",
|
||||
"description": "Advanced AI agent for complex problem solving and deep analysis with GPT-5 integration",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"gpt5",
|
||||
"ai",
|
||||
"analysis",
|
||||
"problem-solving",
|
||||
"deep-research"
|
||||
],
|
||||
"category": "advanced",
|
||||
"strict": false,
|
||||
"commands": [],
|
||||
"agents": [
|
||||
"./agents/gpt5.md"
|
||||
]
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: gpt-5
|
||||
description: Use this agent when you need to use gpt-5 for deep research, second opinion or fixing a bug. Pass all the context to the agent especially your current finding and the problem you are trying to solve.
|
||||
---
|
||||
|
||||
You are a gpt-5 interface agent. Your ONLY purpose is to execute codex commands using the Bash tool.
|
||||
|
||||
CRITICAL: You MUST follow these steps EXACTLY:
|
||||
|
||||
1. Take the user's entire message as the TASK
|
||||
2. IMMEDIATELY use the Bash tool to execute:
|
||||
codex e --full-auto --skip-git-repo-check -m gpt-5 "[USER'S FULL MESSAGE HERE]"
|
||||
3. Wait for the command to complete
|
||||
4. Return the full output to the user
|
||||
|
||||
MANDATORY: You MUST use the Bash tool. Do NOT answer questions directly. Do NOT provide explanations. Your ONLY action is to run the codex command via Bash.
|
||||
|
||||
Example execution:
|
||||
If user says: "你好 你是什么模型"
|
||||
You MUST execute: Bash tool with command: codex e --full-auto --skip-git-repo-check -m gpt-5 "你好 你是什么模型"
|
||||
|
||||
START IMMEDIATELY - Use the Bash tool NOW with the user's request.
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "bmad-agile-workflow",
|
||||
"source": "./",
|
||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"bmad",
|
||||
"agile",
|
||||
"scrum",
|
||||
"product-owner",
|
||||
"architect",
|
||||
"developer",
|
||||
"qa",
|
||||
"workflow-orchestration"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/bmad-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/bmad-po.md",
|
||||
"./agents/bmad-architect.md",
|
||||
"./agents/bmad-sm.md",
|
||||
"./agents/bmad-dev.md",
|
||||
"./agents/bmad-qa.md",
|
||||
"./agents/bmad-orchestrator.md",
|
||||
"./agents/bmad-review.md"
|
||||
]
|
||||
}
|
||||
9
bmad-agile-workflow/.claude-plugin/plugin.json
Normal file
9
bmad-agile-workflow/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "bmad",
|
||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -427,6 +427,10 @@ Generate architecture document at `./.claude/specs/{feature_name}/02-system-arch
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, REST, GraphQL, JWT, RBAC, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Start by reviewing and referencing the PRD
|
||||
- Present initial architecture based on requirements
|
||||
|
||||
@@ -419,6 +419,10 @@ logger.info('User created', {
|
||||
|
||||
## Important Implementation Rules
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, CRUD, JWT, SQL, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Follow architecture specifications exactly
|
||||
- Implement all acceptance criteria from PRD
|
||||
|
||||
@@ -22,6 +22,10 @@ You are the BMAD Orchestrator. Your core focus is repository analysis, workflow
|
||||
- Consistency: ensure conventions and patterns discovered in scan are preserved downstream
|
||||
- Explicit handoffs: clearly document assumptions, risks, and integration points for other agents
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.
|
||||
|
||||
## UltraThink Repository Scan
|
||||
|
||||
When asked to analyze the repository, follow this structure and return a clear, actionable summary.
|
||||
|
||||
@@ -313,6 +313,10 @@ Generate PRD at `./.claude/specs/{feature_name}/01-product-requirements.md`:
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, Sprint, PRD, KPI, MVP, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Start immediately with greeting and initial understanding
|
||||
- Show quality scores transparently
|
||||
|
||||
@@ -478,6 +478,10 @@ module.exports = {
|
||||
|
||||
## Important Testing Rules
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, Mock, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Test all acceptance criteria from PRD
|
||||
- Cover happy path, edge cases, and error scenarios
|
||||
|
||||
@@ -45,3 +45,7 @@ You are an independent code review agent responsible for conducting reviews betw
|
||||
- Focus on actionable findings
|
||||
- Provide specific QA guidance
|
||||
- Use clear, parseable output format
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, PRD, Sprint, etc.) in English; translate explanatory text only.
|
||||
|
||||
@@ -351,6 +351,10 @@ So that [benefit]
|
||||
|
||||
## Important Behaviors
|
||||
|
||||
### Language Rules:
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (Sprint, Epic, Story, Backlog, Velocity, etc.) in English; translate explanatory text only.
|
||||
|
||||
### DO:
|
||||
- Read both PRD and Architecture documents thoroughly
|
||||
- Create comprehensive task breakdown
|
||||
|
||||
72
cliff.toml
Normal file
72
cliff.toml
Normal file
@@ -0,0 +1,72 @@
|
||||
# git-cliff configuration file
|
||||
# https://git-cliff.org/docs/configuration
|
||||
|
||||
[changelog]
|
||||
# changelog header
|
||||
header = """
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
"""
|
||||
# template for the changelog body
|
||||
body = """
|
||||
{% if version %}
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}
|
||||
## Unreleased
|
||||
{% endif %}
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group }}
|
||||
|
||||
{% for commit in commits %}
|
||||
- {{ commit.message | split(pat="\n") | first }}
|
||||
{% endfor -%}
|
||||
{% endfor -%}
|
||||
"""
|
||||
# remove the leading and trailing whitespace from the template
|
||||
trim = true
|
||||
# changelog footer
|
||||
footer = """
|
||||
<!-- generated by git-cliff -->
|
||||
"""
|
||||
|
||||
[git]
|
||||
# parse the commits based on https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
# filter out the commits that are not conventional
|
||||
filter_unconventional = false
|
||||
# process each line of a commit as an individual commit
|
||||
split_commits = false
|
||||
# regex for preprocessing the commit messages
|
||||
commit_preprocessors = [
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/cexll/myclaude/issues/${2}))" },
|
||||
]
|
||||
# regex for parsing and grouping commits
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "🚀 Features" },
|
||||
{ message = "^fix", group = "🐛 Bug Fixes" },
|
||||
{ message = "^doc", group = "📚 Documentation" },
|
||||
{ message = "^perf", group = "⚡ Performance" },
|
||||
{ message = "^refactor", group = "🚜 Refactor" },
|
||||
{ message = "^style", group = "🎨 Styling" },
|
||||
{ message = "^test", group = "🧪 Testing" },
|
||||
{ message = "^chore\\(release\\):", skip = true },
|
||||
{ message = "^chore", group = "⚙️ Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "🛡️ Security" },
|
||||
{ message = "^revert", group = "◀️ Revert" },
|
||||
{ message = ".*", group = "💼 Other" },
|
||||
]
|
||||
# protect breaking changes from being skipped due to matching a skipping commit_parser
|
||||
protect_breaking_commits = false
|
||||
# filter out the commits that are not matched by commit parsers
|
||||
filter_commits = false
|
||||
# glob pattern for matching git tags
|
||||
tag_pattern = "v[0-9]*"
|
||||
# regex for skipping tags
|
||||
skip_tags = "v0.1.0-beta.1"
|
||||
# regex for ignoring tags
|
||||
ignore_tags = ""
|
||||
# sort the tags topologically
|
||||
topo_order = false
|
||||
# sort the commits inside sections by oldest/newest order
|
||||
sort_commits = "newest"
|
||||
11
codeagent-wrapper/.gitignore
vendored
Normal file
11
codeagent-wrapper/.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
# Build artifacts
|
||||
codeagent-wrapper
|
||||
codeagent-wrapper.exe
|
||||
*.test
|
||||
|
||||
# Coverage reports
|
||||
coverage.out
|
||||
coverage*.out
|
||||
cover.out
|
||||
cover_*.out
|
||||
coverage.html
|
||||
79
codeagent-wrapper/agent_config.go
Normal file
79
codeagent-wrapper/agent_config.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type AgentModelConfig struct {
|
||||
Backend string `json:"backend"`
|
||||
Model string `json:"model"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Yolo bool `json:"yolo,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
}
|
||||
|
||||
type ModelsConfig struct {
|
||||
DefaultBackend string `json:"default_backend"`
|
||||
DefaultModel string `json:"default_model"`
|
||||
Agents map[string]AgentModelConfig `json:"agents"`
|
||||
}
|
||||
|
||||
var defaultModelsConfig = ModelsConfig{
|
||||
DefaultBackend: "opencode",
|
||||
DefaultModel: "opencode/grok-code",
|
||||
Agents: map[string]AgentModelConfig{
|
||||
"oracle": {Backend: "claude", Model: "claude-opus-4-5-20251101", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"},
|
||||
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250929", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"},
|
||||
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"},
|
||||
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"},
|
||||
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"},
|
||||
"document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"},
|
||||
},
|
||||
}
|
||||
|
||||
func loadModelsConfig() *ModelsConfig {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
logWarn(fmt.Sprintf("Failed to resolve home directory for models config: %v; using defaults", err))
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
|
||||
configPath := filepath.Join(home, ".codeagent", "models.json")
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
logWarn(fmt.Sprintf("Failed to read models config %s: %v; using defaults", configPath, err))
|
||||
}
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
|
||||
var cfg ModelsConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
logWarn(fmt.Sprintf("Failed to parse models config %s: %v; using defaults", configPath, err))
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
|
||||
// Merge with defaults
|
||||
for name, agent := range defaultModelsConfig.Agents {
|
||||
if _, exists := cfg.Agents[name]; !exists {
|
||||
if cfg.Agents == nil {
|
||||
cfg.Agents = make(map[string]AgentModelConfig)
|
||||
}
|
||||
cfg.Agents[name] = agent
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg
|
||||
}
|
||||
|
||||
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning string, yolo bool) {
|
||||
cfg := loadModelsConfig()
|
||||
if agent, ok := cfg.Agents[agentName]; ok {
|
||||
return agent.Backend, agent.Model, agent.PromptFile, agent.Reasoning, agent.Yolo
|
||||
}
|
||||
return cfg.DefaultBackend, cfg.DefaultModel, "", "", false
|
||||
}
|
||||
208
codeagent-wrapper/agent_config_test.go
Normal file
208
codeagent-wrapper/agent_config_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAgentConfig_Defaults(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
// Test that default agents resolve correctly without config file
|
||||
tests := []struct {
|
||||
agent string
|
||||
wantBackend string
|
||||
wantModel string
|
||||
wantPromptFile string
|
||||
}{
|
||||
{"oracle", "claude", "claude-opus-4-5-20251101", "~/.claude/skills/omo/references/oracle.md"},
|
||||
{"librarian", "claude", "claude-sonnet-4-5-20250929", "~/.claude/skills/omo/references/librarian.md"},
|
||||
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
|
||||
{"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
|
||||
{"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.agent, func(t *testing.T) {
|
||||
backend, model, promptFile, _, _ := resolveAgentConfig(tt.agent)
|
||||
if backend != tt.wantBackend {
|
||||
t.Errorf("backend = %q, want %q", backend, tt.wantBackend)
|
||||
}
|
||||
if model != tt.wantModel {
|
||||
t.Errorf("model = %q, want %q", model, tt.wantModel)
|
||||
}
|
||||
if promptFile != tt.wantPromptFile {
|
||||
t.Errorf("promptFile = %q, want %q", promptFile, tt.wantPromptFile)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_UnknownAgent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
backend, model, promptFile, _, _ := resolveAgentConfig("unknown-agent")
|
||||
if backend != "opencode" {
|
||||
t.Errorf("unknown agent backend = %q, want %q", backend, "opencode")
|
||||
}
|
||||
if model != "opencode/grok-code" {
|
||||
t.Errorf("unknown agent model = %q, want %q", model, "opencode/grok-code")
|
||||
}
|
||||
if promptFile != "" {
|
||||
t.Errorf("unknown agent promptFile = %q, want empty", promptFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_NoFile(t *testing.T) {
|
||||
home := "/nonexistent/path/that/does/not/exist"
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
if cfg.DefaultBackend != "opencode" {
|
||||
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "opencode")
|
||||
}
|
||||
if len(cfg.Agents) != 6 {
|
||||
t.Errorf("len(Agents) = %d, want 6", len(cfg.Agents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_WithFile(t *testing.T) {
|
||||
// Create temp dir and config file
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
configContent := `{
|
||||
"default_backend": "claude",
|
||||
"default_model": "claude-opus-4",
|
||||
"agents": {
|
||||
"custom-agent": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-4o",
|
||||
"description": "Custom agent"
|
||||
}
|
||||
}
|
||||
}`
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
|
||||
if cfg.DefaultBackend != "claude" {
|
||||
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude")
|
||||
}
|
||||
if cfg.DefaultModel != "claude-opus-4" {
|
||||
t.Errorf("DefaultModel = %q, want %q", cfg.DefaultModel, "claude-opus-4")
|
||||
}
|
||||
|
||||
// Check custom agent
|
||||
if agent, ok := cfg.Agents["custom-agent"]; !ok {
|
||||
t.Error("custom-agent not found")
|
||||
} else {
|
||||
if agent.Backend != "codex" {
|
||||
t.Errorf("custom-agent.Backend = %q, want %q", agent.Backend, "codex")
|
||||
}
|
||||
if agent.Model != "gpt-4o" {
|
||||
t.Errorf("custom-agent.Model = %q, want %q", agent.Model, "gpt-4o")
|
||||
}
|
||||
}
|
||||
|
||||
// Check that defaults are merged
|
||||
if _, ok := cfg.Agents["oracle"]; !ok {
|
||||
t.Error("default agent oracle should be merged")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_InvalidJSON(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Write invalid JSON
|
||||
configPath := filepath.Join(configDir, "models.json")
|
||||
if err := os.WriteFile(configPath, []byte("invalid json {"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
// Should fall back to defaults
|
||||
if cfg.DefaultBackend != "opencode" {
|
||||
t.Errorf("invalid JSON should fallback, got DefaultBackend = %q", cfg.DefaultBackend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpencodeBackend_BuildArgs(t *testing.T) {
|
||||
backend := OpencodeBackend{}
|
||||
|
||||
t.Run("basic", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "new"}
|
||||
got := backend.BuildArgs(cfg, "hello")
|
||||
want := []string{"run", "--format", "json", "hello"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("with model", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "new", Model: "opencode/grok-code"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"run", "-m", "opencode/grok-code", "--format", "json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "resume", SessionID: "ses_123", Model: "opencode/grok-code"}
|
||||
got := backend.BuildArgs(cfg, "follow-up")
|
||||
want := []string{"run", "-m", "opencode/grok-code", "-s", "ses_123", "--format", "json", "follow-up"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume without session", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "resume"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"run", "--format", "json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpencodeBackend_Interface(t *testing.T) {
|
||||
backend := OpencodeBackend{}
|
||||
|
||||
if backend.Name() != "opencode" {
|
||||
t.Errorf("Name() = %q, want %q", backend.Name(), "opencode")
|
||||
}
|
||||
if backend.Command() != "opencode" {
|
||||
t.Errorf("Command() = %q, want %q", backend.Command(), "opencode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendRegistry_IncludesOpencode(t *testing.T) {
|
||||
if _, ok := backendRegistry["opencode"]; !ok {
|
||||
t.Error("backendRegistry should include opencode")
|
||||
}
|
||||
}
|
||||
147
codeagent-wrapper/agent_validation_test.go
Normal file
147
codeagent-wrapper/agent_validation_test.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValidateAgentName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "simple", input: "develop", wantErr: false},
|
||||
{name: "upper", input: "ABC", wantErr: false},
|
||||
{name: "digits", input: "a1", wantErr: false},
|
||||
{name: "dash underscore", input: "a-b_c", wantErr: false},
|
||||
{name: "empty", input: "", wantErr: true},
|
||||
{name: "space", input: "a b", wantErr: true},
|
||||
{name: "slash", input: "a/b", wantErr: true},
|
||||
{name: "dotdot", input: "../evil", wantErr: true},
|
||||
{name: "unicode", input: "中文", wantErr: true},
|
||||
{name: "symbol", input: "a$b", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateAgentName(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateAgentName(%q) err=%v, wantErr=%v", tt.input, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArgs_InvalidAgentNameRejected(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
os.Args = []string{"codeagent-wrapper", "--agent", "../evil", "task"}
|
||||
if _, err := parseArgs(); err == nil {
|
||||
t.Fatalf("expected parseArgs to reject invalid agent name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParallelConfig_InvalidAgentNameRejected(t *testing.T) {
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
agent: ../evil
|
||||
---CONTENT---
|
||||
do something`
|
||||
if _, err := parseParallelConfig([]byte(input)); err == nil {
|
||||
t.Fatalf("expected parseParallelConfig to reject invalid agent name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseParallelConfig_ResolvesAgentPromptFile(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-test",
|
||||
"agents": {
|
||||
"custom-agent": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-test",
|
||||
"prompt_file": "~/.claude/prompt.md"
|
||||
}
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
agent: custom-agent
|
||||
---CONTENT---
|
||||
do something`
|
||||
cfg, err := parseParallelConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
||||
}
|
||||
if got := cfg.Tasks[0].PromptFile; got != "~/.claude/prompt.md" {
|
||||
t.Fatalf("PromptFile = %q, want %q", got, "~/.claude/prompt.md")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRunCodexTaskFn_AppliesAgentPromptFile(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(claudeDir, "prompt.md"), []byte("P\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
fake := newFakeCmd(fakeCmdConfig{
|
||||
StdoutPlan: []fakeStdoutEvent{
|
||||
{Data: `{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}` + "\n"},
|
||||
},
|
||||
WaitDelay: 2 * time.Millisecond,
|
||||
})
|
||||
|
||||
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
return fake
|
||||
}
|
||||
selectBackendFn = func(name string) (Backend, error) {
|
||||
return testBackend{
|
||||
name: name,
|
||||
command: "fake-cmd",
|
||||
argsFn: func(cfg *Config, targetArg string) []string {
|
||||
return []string{targetArg}
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
res := defaultRunCodexTaskFn(TaskSpec{
|
||||
ID: "t",
|
||||
Task: "do",
|
||||
Backend: "codex",
|
||||
PromptFile: "~/.claude/prompt.md",
|
||||
}, 5)
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
|
||||
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
|
||||
if got := fake.StdinContents(); got != want {
|
||||
t.Fatalf("stdin mismatch:\n got=%q\nwant=%q", got, want)
|
||||
}
|
||||
}
|
||||
237
codeagent-wrapper/backend.go
Normal file
237
codeagent-wrapper/backend.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Backend defines the contract for invoking different AI CLI backends.
|
||||
// Each backend is responsible for supplying the executable command and
|
||||
// building the argument list based on the wrapper config.
|
||||
type Backend interface {
|
||||
Name() string
|
||||
BuildArgs(cfg *Config, targetArg string) []string
|
||||
Command() string
|
||||
}
|
||||
|
||||
type CodexBackend struct{}
|
||||
|
||||
func (CodexBackend) Name() string { return "codex" }
|
||||
func (CodexBackend) Command() string {
|
||||
return "codex"
|
||||
}
|
||||
func (CodexBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
return buildCodexArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
type ClaudeBackend struct{}
|
||||
|
||||
func (ClaudeBackend) Name() string { return "claude" }
|
||||
func (ClaudeBackend) Command() string {
|
||||
return "claude"
|
||||
}
|
||||
func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
return buildClaudeArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
const maxClaudeSettingsBytes = 1 << 20 // 1MB
|
||||
|
||||
type minimalClaudeSettings struct {
|
||||
Env map[string]string
|
||||
Model string
|
||||
}
|
||||
|
||||
// loadMinimalClaudeSettings 从 ~/.claude/settings.json 只提取安全的最小子集:
|
||||
// - env: 只接受字符串类型的值
|
||||
// - model: 只接受字符串类型的值
|
||||
// 文件缺失/解析失败/超限都返回空。
|
||||
func loadMinimalClaudeSettings() minimalClaudeSettings {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return minimalClaudeSettings{}
|
||||
}
|
||||
|
||||
settingPath := filepath.Join(home, ".claude", "settings.json")
|
||||
info, err := os.Stat(settingPath)
|
||||
if err != nil || info.Size() > maxClaudeSettingsBytes {
|
||||
return minimalClaudeSettings{}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(settingPath)
|
||||
if err != nil {
|
||||
return minimalClaudeSettings{}
|
||||
}
|
||||
|
||||
var cfg struct {
|
||||
Env map[string]any `json:"env"`
|
||||
Model any `json:"model"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
return minimalClaudeSettings{}
|
||||
}
|
||||
|
||||
out := minimalClaudeSettings{}
|
||||
|
||||
if model, ok := cfg.Model.(string); ok {
|
||||
out.Model = strings.TrimSpace(model)
|
||||
}
|
||||
|
||||
if len(cfg.Env) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
env := make(map[string]string, len(cfg.Env))
|
||||
for k, v := range cfg.Env {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
env[k] = s
|
||||
}
|
||||
if len(env) == 0 {
|
||||
return out
|
||||
}
|
||||
out.Env = env
|
||||
return out
|
||||
}
|
||||
|
||||
// loadMinimalEnvSettings is kept for backwards tests; prefer loadMinimalClaudeSettings.
|
||||
func loadMinimalEnvSettings() map[string]string {
|
||||
settings := loadMinimalClaudeSettings()
|
||||
if len(settings.Env) == 0 {
|
||||
return nil
|
||||
}
|
||||
return settings.Env
|
||||
}
|
||||
|
||||
// loadGeminiEnv loads environment variables from ~/.gemini/.env
|
||||
// Supports GEMINI_API_KEY, GEMINI_MODEL, GOOGLE_GEMINI_BASE_URL
|
||||
// Also sets GEMINI_API_KEY_AUTH_MECHANISM=bearer for third-party API compatibility
|
||||
func loadGeminiEnv() map[string]string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || home == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
envPath := filepath.Join(home, ".gemini", ".env")
|
||||
data, err := os.ReadFile(envPath)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
env := make(map[string]string)
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
idx := strings.IndexByte(line, '=')
|
||||
if idx <= 0 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(line[:idx])
|
||||
value := strings.TrimSpace(line[idx+1:])
|
||||
if key != "" && value != "" {
|
||||
env[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
// Set bearer auth mechanism for third-party API compatibility
|
||||
if _, ok := env["GEMINI_API_KEY"]; ok {
|
||||
if _, hasAuth := env["GEMINI_API_KEY_AUTH_MECHANISM"]; !hasAuth {
|
||||
env["GEMINI_API_KEY_AUTH_MECHANISM"] = "bearer"
|
||||
}
|
||||
}
|
||||
|
||||
if len(env) == 0 {
|
||||
return nil
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func buildClaudeArgs(cfg *Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-p"}
|
||||
// Default to skip permissions unless CODEAGENT_SKIP_PERMISSIONS=false
|
||||
if cfg.SkipPermissions || cfg.Yolo || envFlagDefaultTrue("CODEAGENT_SKIP_PERMISSIONS") {
|
||||
args = append(args, "--dangerously-skip-permissions")
|
||||
}
|
||||
|
||||
// Prevent infinite recursion: disable all setting sources (user, project, local)
|
||||
// This ensures a clean execution environment without CLAUDE.md or skills that would trigger codeagent
|
||||
args = append(args, "--setting-sources", "")
|
||||
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "--model", model)
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
// Claude CLI uses -r <session_id> for resume.
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
// Note: claude CLI doesn't support -C flag; workdir set via cmd.Dir
|
||||
|
||||
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
type GeminiBackend struct{}
|
||||
|
||||
func (GeminiBackend) Name() string { return "gemini" }
|
||||
func (GeminiBackend) Command() string {
|
||||
return "gemini"
|
||||
}
|
||||
func (GeminiBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
return buildGeminiArgs(cfg, targetArg)
|
||||
}
|
||||
|
||||
type OpencodeBackend struct{}
|
||||
|
||||
func (OpencodeBackend) Name() string { return "opencode" }
|
||||
func (OpencodeBackend) Command() string { return "opencode" }
|
||||
func (OpencodeBackend) BuildArgs(cfg *Config, targetArg string) []string {
|
||||
args := []string{"run"}
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
if cfg.Mode == "resume" && cfg.SessionID != "" {
|
||||
args = append(args, "-s", cfg.SessionID)
|
||||
}
|
||||
args = append(args, "--format", "json", targetArg)
|
||||
return args
|
||||
}
|
||||
|
||||
func buildGeminiArgs(cfg *Config, targetArg string) []string {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
args := []string{"-o", "stream-json", "-y"}
|
||||
|
||||
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||
args = append(args, "-m", model)
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" {
|
||||
if cfg.SessionID != "" {
|
||||
args = append(args, "-r", cfg.SessionID)
|
||||
}
|
||||
}
|
||||
// Note: gemini CLI doesn't support -C flag; workdir set via cmd.Dir
|
||||
|
||||
// Use positional argument instead of deprecated -p flag
|
||||
// For stdin mode ("-"), use -p to read from stdin
|
||||
if targetArg == "-" {
|
||||
args = append(args, "-p", targetArg)
|
||||
} else {
|
||||
args = append(args, targetArg)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
260
codeagent-wrapper/backend_test.go
Normal file
260
codeagent-wrapper/backend_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
|
||||
backend := ClaudeBackend{}
|
||||
|
||||
t.Run("new mode omits skip-permissions when env disabled", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
cfg := &Config{Mode: "new", WorkDir: "/repo"}
|
||||
got := backend.BuildArgs(cfg, "todo")
|
||||
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "todo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("new mode includes skip-permissions by default", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "new", SkipPermissions: false}
|
||||
got := backend.BuildArgs(cfg, "-")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode includes session id", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
|
||||
got := backend.BuildArgs(cfg, "resume-task")
|
||||
want := []string{"-p", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode without session still returns base flags", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
cfg := &Config{Mode: "resume", WorkDir: "/ignored"}
|
||||
got := backend.BuildArgs(cfg, "follow-up")
|
||||
want := []string{"-p", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode can opt-in skip permissions", func(t *testing.T) {
|
||||
cfg := &Config{Mode: "resume", SessionID: "sid-123", SkipPermissions: true}
|
||||
got := backend.BuildArgs(cfg, "resume-task")
|
||||
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "-r", "sid-123", "--output-format", "stream-json", "--verbose", "resume-task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil config returns nil", func(t *testing.T) {
|
||||
if backend.BuildArgs(nil, "ignored") != nil {
|
||||
t.Fatalf("nil config should return nil args")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBackendBuildArgs_Model(t *testing.T) {
|
||||
t.Run("claude includes --model when set", func(t *testing.T) {
|
||||
t.Setenv("CODEAGENT_SKIP_PERMISSIONS", "false")
|
||||
backend := ClaudeBackend{}
|
||||
cfg := &Config{Mode: "new", Model: "opus"}
|
||||
got := backend.BuildArgs(cfg, "todo")
|
||||
want := []string{"-p", "--setting-sources", "", "--model", "opus", "--output-format", "stream-json", "--verbose", "todo"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini includes -m when set", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "new", Model: "gemini-3-pro-preview"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"-o", "stream-json", "-y", "-m", "gemini-3-pro-preview", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex includes --model when set", func(t *testing.T) {
|
||||
const key = "CODEX_BYPASS_SANDBOX"
|
||||
t.Setenv(key, "false")
|
||||
|
||||
backend := CodexBackend{}
|
||||
cfg := &Config{Mode: "new", WorkDir: "/tmp", Model: "o3"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--model", "o3", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
|
||||
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "new", WorkDir: "/workspace"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"-o", "stream-json", "-y", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini resume mode uses session id", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "resume", SessionID: "sid-999"}
|
||||
got := backend.BuildArgs(cfg, "resume")
|
||||
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "resume"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "resume"}
|
||||
got := backend.BuildArgs(cfg, "resume")
|
||||
want := []string{"-o", "stream-json", "-y", "resume"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini nil config returns nil", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
if backend.BuildArgs(nil, "ignored") != nil {
|
||||
t.Fatalf("nil config should return nil args")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gemini stdin mode uses -p flag", func(t *testing.T) {
|
||||
backend := GeminiBackend{}
|
||||
cfg := &Config{Mode: "new"}
|
||||
got := backend.BuildArgs(cfg, "-")
|
||||
want := []string{"-o", "stream-json", "-y", "-p", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex build args omits bypass flag by default", func(t *testing.T) {
|
||||
const key = "CODEX_BYPASS_SANDBOX"
|
||||
t.Setenv(key, "false")
|
||||
|
||||
backend := CodexBackend{}
|
||||
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("codex build args includes bypass flag when enabled", func(t *testing.T) {
|
||||
const key = "CODEX_BYPASS_SANDBOX"
|
||||
t.Setenv(key, "true")
|
||||
|
||||
backend := CodexBackend{}
|
||||
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
|
||||
got := backend.BuildArgs(cfg, "task")
|
||||
want := []string{"e", "--dangerously-bypass-approvals-and-sandbox", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestClaudeBuildArgs_BackendMetadata(t *testing.T) {
|
||||
tests := []struct {
|
||||
backend Backend
|
||||
name string
|
||||
command string
|
||||
}{
|
||||
{backend: CodexBackend{}, name: "codex", command: "codex"},
|
||||
{backend: ClaudeBackend{}, name: "claude", command: "claude"},
|
||||
{backend: GeminiBackend{}, name: "gemini", command: "gemini"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if got := tt.backend.Name(); got != tt.name {
|
||||
t.Fatalf("Name() = %s, want %s", got, tt.name)
|
||||
}
|
||||
if got := tt.backend.Command(); got != tt.command {
|
||||
t.Fatalf("Command() = %s, want %s", got, tt.command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMinimalEnvSettings(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
t.Run("missing file returns empty", func(t *testing.T) {
|
||||
if got := loadMinimalEnvSettings(); len(got) != 0 {
|
||||
t.Fatalf("got %v, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid env returns string map", func(t *testing.T) {
|
||||
dir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
data := []byte(`{"env":{"ANTHROPIC_API_KEY":"secret","FOO":"bar"}}`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got := loadMinimalEnvSettings()
|
||||
if got["ANTHROPIC_API_KEY"] != "secret" || got["FOO"] != "bar" {
|
||||
t.Fatalf("got %v, want keys present", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("non-string values are ignored", func(t *testing.T) {
|
||||
dir := filepath.Join(home, ".claude")
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
data := []byte(`{"env":{"GOOD":"ok","BAD":123,"ALSO_BAD":true}}`)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got := loadMinimalEnvSettings()
|
||||
if got["GOOD"] != "ok" {
|
||||
t.Fatalf("got %v, want GOOD=ok", got)
|
||||
}
|
||||
if _, ok := got["BAD"]; ok {
|
||||
t.Fatalf("got %v, want BAD omitted", got)
|
||||
}
|
||||
if _, ok := got["ALSO_BAD"]; ok {
|
||||
t.Fatalf("got %v, want ALSO_BAD omitted", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("oversized file returns empty", func(t *testing.T) {
|
||||
dir := filepath.Join(home, ".claude")
|
||||
path := filepath.Join(dir, "settings.json")
|
||||
data := bytes.Repeat([]byte("a"), maxClaudeSettingsBytes+1)
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if got := loadMinimalEnvSettings(); len(got) != 0 {
|
||||
t.Fatalf("got %v, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
39
codeagent-wrapper/bench_test.go
Normal file
39
codeagent-wrapper/bench_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// BenchmarkLoggerWrite 测试日志写入性能
|
||||
func BenchmarkLoggerWrite(b *testing.B) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
logger.Info("benchmark log message")
|
||||
}
|
||||
b.StopTimer()
|
||||
logger.Flush()
|
||||
}
|
||||
|
||||
// BenchmarkLoggerConcurrentWrite 测试并发日志写入性能
|
||||
func BenchmarkLoggerConcurrentWrite(b *testing.B) {
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
logger.Info("concurrent benchmark log message")
|
||||
}
|
||||
})
|
||||
b.StopTimer()
|
||||
logger.Flush()
|
||||
}
|
||||
434
codeagent-wrapper/concurrent_stress_test.go
Normal file
434
codeagent-wrapper/concurrent_stress_test.go
Normal file
@@ -0,0 +1,434 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func stripTimestampPrefix(line string) string {
|
||||
if !strings.HasPrefix(line, "[") {
|
||||
return line
|
||||
}
|
||||
if idx := strings.Index(line, "] "); idx >= 0 {
|
||||
return line[idx+2:]
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// TestConcurrentStressLogger 高并发压力测试
|
||||
func TestConcurrentStressLogger(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping stress test in short mode")
|
||||
}
|
||||
|
||||
logger, err := NewLoggerWithSuffix("stress")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
t.Logf("Log file: %s", logger.Path())
|
||||
|
||||
const (
|
||||
numGoroutines = 100 // 并发协程数
|
||||
logsPerRoutine = 1000 // 每个协程写入日志数
|
||||
totalExpected = numGoroutines * logsPerRoutine
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
|
||||
// 启动并发写入
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < logsPerRoutine; j++ {
|
||||
logger.Info(fmt.Sprintf("goroutine-%d-msg-%d", id, j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
logger.Flush()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 读取日志文件验证
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
actualCount := len(lines)
|
||||
|
||||
t.Logf("Concurrent stress test results:")
|
||||
t.Logf(" Goroutines: %d", numGoroutines)
|
||||
t.Logf(" Logs per goroutine: %d", logsPerRoutine)
|
||||
t.Logf(" Total expected: %d", totalExpected)
|
||||
t.Logf(" Total actual: %d", actualCount)
|
||||
t.Logf(" Duration: %v", elapsed)
|
||||
t.Logf(" Throughput: %.2f logs/sec", float64(totalExpected)/elapsed.Seconds())
|
||||
|
||||
// 验证日志数量
|
||||
if actualCount < totalExpected/10 {
|
||||
t.Errorf("too many logs lost: got %d, want at least %d (10%% of %d)",
|
||||
actualCount, totalExpected/10, totalExpected)
|
||||
}
|
||||
t.Logf("Successfully wrote %d/%d logs (%.1f%%)",
|
||||
actualCount, totalExpected, float64(actualCount)/float64(totalExpected)*100)
|
||||
|
||||
// 验证日志格式(纯文本,无前缀)
|
||||
formatRE := regexp.MustCompile(`^goroutine-\d+-msg-\d+$`)
|
||||
for i, line := range lines[:min(10, len(lines))] {
|
||||
msg := stripTimestampPrefix(line)
|
||||
if !formatRE.MatchString(msg) {
|
||||
t.Errorf("line %d has invalid format: %s", i, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConcurrentBurstLogger 突发流量测试
|
||||
func TestConcurrentBurstLogger(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping burst test in short mode")
|
||||
}
|
||||
|
||||
logger, err := NewLoggerWithSuffix("burst")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
t.Logf("Log file: %s", logger.Path())
|
||||
|
||||
const (
|
||||
numBursts = 10
|
||||
goroutinesPerBurst = 50
|
||||
logsPerGoroutine = 100
|
||||
)
|
||||
|
||||
totalLogs := 0
|
||||
start := time.Now()
|
||||
|
||||
// 模拟突发流量
|
||||
for burst := 0; burst < numBursts; burst++ {
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < goroutinesPerBurst; i++ {
|
||||
wg.Add(1)
|
||||
totalLogs += logsPerGoroutine
|
||||
go func(b, g int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < logsPerGoroutine; j++ {
|
||||
logger.Info(fmt.Sprintf("burst-%d-goroutine-%d-msg-%d", b, g, j))
|
||||
}
|
||||
}(burst, i)
|
||||
}
|
||||
wg.Wait()
|
||||
time.Sleep(10 * time.Millisecond) // 突发间隔
|
||||
}
|
||||
|
||||
logger.Flush()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 验证
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read log file: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
actualCount := len(lines)
|
||||
|
||||
t.Logf("Burst test results:")
|
||||
t.Logf(" Total bursts: %d", numBursts)
|
||||
t.Logf(" Goroutines per burst: %d", goroutinesPerBurst)
|
||||
t.Logf(" Expected logs: %d", totalLogs)
|
||||
t.Logf(" Actual logs: %d", actualCount)
|
||||
t.Logf(" Duration: %v", elapsed)
|
||||
t.Logf(" Throughput: %.2f logs/sec", float64(totalLogs)/elapsed.Seconds())
|
||||
|
||||
if actualCount < totalLogs/10 {
|
||||
t.Errorf("too many logs lost: got %d, want at least %d (10%% of %d)", actualCount, totalLogs/10, totalLogs)
|
||||
}
|
||||
t.Logf("Successfully wrote %d/%d logs (%.1f%%)",
|
||||
actualCount, totalLogs, float64(actualCount)/float64(totalLogs)*100)
|
||||
}
|
||||
|
||||
// TestLoggerChannelCapacity 测试 channel 容量极限
|
||||
func TestLoggerChannelCapacity(t *testing.T) {
|
||||
logger, err := NewLoggerWithSuffix("capacity")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
const rapidLogs = 2000 // 超过 channel 容量 (1000)
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < rapidLogs; i++ {
|
||||
logger.Info(fmt.Sprintf("rapid-log-%d", i))
|
||||
}
|
||||
sendDuration := time.Since(start)
|
||||
|
||||
logger.Flush()
|
||||
flushDuration := time.Since(start) - sendDuration
|
||||
|
||||
t.Logf("Channel capacity test:")
|
||||
t.Logf(" Logs sent: %d", rapidLogs)
|
||||
t.Logf(" Send duration: %v", sendDuration)
|
||||
t.Logf(" Flush duration: %v", flushDuration)
|
||||
|
||||
// 验证仍有合理比例的日志写入(非阻塞模式允许部分丢失)
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
actualCount := len(lines)
|
||||
|
||||
if actualCount < rapidLogs/10 {
|
||||
t.Errorf("too many logs lost: got %d, want at least %d (10%% of %d)", actualCount, rapidLogs/10, rapidLogs)
|
||||
}
|
||||
t.Logf("Logs persisted: %d/%d (%.1f%%)", actualCount, rapidLogs, float64(actualCount)/float64(rapidLogs)*100)
|
||||
}
|
||||
|
||||
// TestLoggerMemoryUsage 内存使用测试
|
||||
func TestLoggerMemoryUsage(t *testing.T) {
|
||||
logger, err := NewLoggerWithSuffix("memory")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
const numLogs = 20000
|
||||
longMessage := strings.Repeat("x", 500) // 500 字节长消息
|
||||
|
||||
start := time.Now()
|
||||
for i := 0; i < numLogs; i++ {
|
||||
logger.Info(fmt.Sprintf("log-%d-%s", i, longMessage))
|
||||
}
|
||||
logger.Flush()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// 检查文件大小
|
||||
info, err := os.Stat(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expectedTotalSize := int64(numLogs * 500) // 理论最小总字节数
|
||||
expectedMinSize := expectedTotalSize / 10 // 接受最多 90% 丢失
|
||||
actualSize := info.Size()
|
||||
|
||||
t.Logf("Memory/disk usage test:")
|
||||
t.Logf(" Logs written: %d", numLogs)
|
||||
t.Logf(" Message size: 500 bytes")
|
||||
t.Logf(" File size: %.2f MB", float64(actualSize)/1024/1024)
|
||||
t.Logf(" Duration: %v", elapsed)
|
||||
t.Logf(" Write speed: %.2f MB/s", float64(actualSize)/1024/1024/elapsed.Seconds())
|
||||
t.Logf(" Persistence ratio: %.1f%%", float64(actualSize)/float64(expectedTotalSize)*100)
|
||||
|
||||
if actualSize < expectedMinSize {
|
||||
t.Errorf("file size too small: got %d bytes, expected at least %d", actualSize, expectedMinSize)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerFlushTimeout 测试 Flush 超时机制
|
||||
func TestLoggerFlushTimeout(t *testing.T) {
|
||||
logger, err := NewLoggerWithSuffix("flush")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
// 写入一些日志
|
||||
for i := 0; i < 100; i++ {
|
||||
logger.Info(fmt.Sprintf("test-log-%d", i))
|
||||
}
|
||||
|
||||
// 测试 Flush 应该在合理时间内完成
|
||||
start := time.Now()
|
||||
logger.Flush()
|
||||
duration := time.Since(start)
|
||||
|
||||
t.Logf("Flush duration: %v", duration)
|
||||
|
||||
if duration > 6*time.Second {
|
||||
t.Errorf("Flush took too long: %v (expected < 6s)", duration)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoggerOrderPreservation 测试日志顺序保持
|
||||
func TestLoggerOrderPreservation(t *testing.T) {
|
||||
logger, err := NewLoggerWithSuffix("order")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer logger.Close()
|
||||
|
||||
const numGoroutines = 10
|
||||
const logsPerRoutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < logsPerRoutine; j++ {
|
||||
logger.Info(fmt.Sprintf("G%d-SEQ%04d", id, j))
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
logger.Flush()
|
||||
|
||||
// 读取并验证每个 goroutine 的日志顺序
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(string(data)))
|
||||
sequences := make(map[int][]int) // goroutine ID -> sequence numbers
|
||||
|
||||
for scanner.Scan() {
|
||||
line := stripTimestampPrefix(scanner.Text())
|
||||
var gid, seq int
|
||||
// Parse format: G0-SEQ0001 (without INFO: prefix)
|
||||
_, err := fmt.Sscanf(line, "G%d-SEQ%04d", &gid, &seq)
|
||||
if err != nil {
|
||||
t.Errorf("invalid log format: %s (error: %v)", line, err)
|
||||
continue
|
||||
}
|
||||
sequences[gid] = append(sequences[gid], seq)
|
||||
}
|
||||
|
||||
// 验证每个 goroutine 内部顺序
|
||||
for gid, seqs := range sequences {
|
||||
for i := 0; i < len(seqs)-1; i++ {
|
||||
if seqs[i] >= seqs[i+1] {
|
||||
t.Errorf("Goroutine %d: out of order at index %d: %d >= %d",
|
||||
gid, i, seqs[i], seqs[i+1])
|
||||
}
|
||||
}
|
||||
if len(seqs) != logsPerRoutine {
|
||||
t.Errorf("Goroutine %d: missing logs, got %d, want %d",
|
||||
gid, len(seqs), logsPerRoutine)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Order preservation test: all %d goroutines maintained sequence order", len(sequences))
|
||||
}
|
||||
|
||||
func TestConcurrentWorkerPoolLimit(t *testing.T) {
|
||||
orig := runCodexTaskFn
|
||||
defer func() { runCodexTaskFn = orig }()
|
||||
|
||||
logger, err := NewLoggerWithSuffix("pool-limit")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
setLogger(logger)
|
||||
t.Cleanup(func() {
|
||||
_ = closeLogger()
|
||||
_ = logger.RemoveLogFile()
|
||||
})
|
||||
|
||||
var active int64
|
||||
var maxSeen int64
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
if task.Context == nil {
|
||||
t.Fatalf("context not propagated for task %s", task.ID)
|
||||
}
|
||||
cur := atomic.AddInt64(&active, 1)
|
||||
for {
|
||||
prev := atomic.LoadInt64(&maxSeen)
|
||||
if cur <= prev || atomic.CompareAndSwapInt64(&maxSeen, prev, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-task.Context.Done():
|
||||
atomic.AddInt64(&active, -1)
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 130, Error: "context cancelled"}
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
atomic.AddInt64(&active, -1)
|
||||
return TaskResult{TaskID: task.ID}
|
||||
}
|
||||
|
||||
layers := [][]TaskSpec{{{ID: "t1"}, {ID: "t2"}, {ID: "t3"}, {ID: "t4"}, {ID: "t5"}}}
|
||||
results := executeConcurrentWithContext(context.Background(), layers, 5, 2)
|
||||
|
||||
if len(results) != 5 {
|
||||
t.Fatalf("unexpected result count: got %d", len(results))
|
||||
}
|
||||
if maxSeen > 2 {
|
||||
t.Fatalf("worker pool exceeded limit: saw %d active workers", maxSeen)
|
||||
}
|
||||
|
||||
logger.Flush()
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read log file: %v", err)
|
||||
}
|
||||
content := string(data)
|
||||
if !strings.Contains(content, "worker_limit=2") {
|
||||
t.Fatalf("concurrency planning log missing, content: %s", content)
|
||||
}
|
||||
if !strings.Contains(content, "parallel: start") {
|
||||
t.Fatalf("concurrency start logs missing, content: %s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentCancellationPropagation(t *testing.T) {
|
||||
orig := runCodexTaskFn
|
||||
defer func() { runCodexTaskFn = orig }()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
if task.Context == nil {
|
||||
t.Fatalf("context not propagated for task %s", task.ID)
|
||||
}
|
||||
select {
|
||||
case <-task.Context.Done():
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 130, Error: "context cancelled"}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return TaskResult{TaskID: task.ID}
|
||||
}
|
||||
}
|
||||
|
||||
layers := [][]TaskSpec{{{ID: "a"}, {ID: "b"}, {ID: "c"}}}
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
results := executeConcurrentWithContext(ctx, layers, 1, 2)
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("unexpected result count: got %d", len(results))
|
||||
}
|
||||
|
||||
cancelled := 0
|
||||
for _, res := range results {
|
||||
if res.ExitCode != 0 {
|
||||
cancelled++
|
||||
}
|
||||
}
|
||||
|
||||
if cancelled == 0 {
|
||||
t.Fatalf("expected cancellation to propagate, got results: %+v", results)
|
||||
}
|
||||
}
|
||||
473
codeagent-wrapper/config.go
Normal file
473
codeagent-wrapper/config.go
Normal file
@@ -0,0 +1,473 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds CLI configuration
|
||||
type Config struct {
|
||||
Mode string // "new" or "resume"
|
||||
Task string
|
||||
SessionID string
|
||||
WorkDir string
|
||||
Model string
|
||||
ReasoningEffort string
|
||||
ExplicitStdin bool
|
||||
Timeout int
|
||||
Backend string
|
||||
Agent string
|
||||
PromptFile string
|
||||
PromptFileExplicit bool
|
||||
SkipPermissions bool
|
||||
Yolo bool
|
||||
MaxParallelWorkers int
|
||||
}
|
||||
|
||||
// ParallelConfig defines the JSON schema for parallel execution
|
||||
type ParallelConfig struct {
|
||||
Tasks []TaskSpec `json:"tasks"`
|
||||
GlobalBackend string `json:"backend,omitempty"`
|
||||
}
|
||||
|
||||
// TaskSpec describes an individual task entry in the parallel config
|
||||
type TaskSpec struct {
|
||||
ID string `json:"id"`
|
||||
Task string `json:"task"`
|
||||
WorkDir string `json:"workdir,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Backend string `json:"backend,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
ReasoningEffort string `json:"reasoning_effort,omitempty"`
|
||||
Agent string `json:"agent,omitempty"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
SkipPermissions bool `json:"skip_permissions,omitempty"`
|
||||
Mode string `json:"-"`
|
||||
UseStdin bool `json:"-"`
|
||||
Context context.Context `json:"-"`
|
||||
}
|
||||
|
||||
// TaskResult captures the execution outcome of a task
|
||||
type TaskResult struct {
|
||||
TaskID string `json:"task_id"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Message string `json:"message"`
|
||||
SessionID string `json:"session_id"`
|
||||
Error string `json:"error"`
|
||||
LogPath string `json:"log_path"`
|
||||
// Structured report fields
|
||||
Coverage string `json:"coverage,omitempty"` // extracted coverage percentage (e.g., "92%")
|
||||
CoverageNum float64 `json:"coverage_num,omitempty"` // numeric coverage for comparison
|
||||
CoverageTarget float64 `json:"coverage_target,omitempty"` // target coverage (default 90)
|
||||
FilesChanged []string `json:"files_changed,omitempty"` // list of changed files
|
||||
KeyOutput string `json:"key_output,omitempty"` // brief summary of what was done
|
||||
TestsPassed int `json:"tests_passed,omitempty"` // number of tests passed
|
||||
TestsFailed int `json:"tests_failed,omitempty"` // number of tests failed
|
||||
sharedLog bool
|
||||
}
|
||||
|
||||
var backendRegistry = map[string]Backend{
|
||||
"codex": CodexBackend{},
|
||||
"claude": ClaudeBackend{},
|
||||
"gemini": GeminiBackend{},
|
||||
"opencode": OpencodeBackend{},
|
||||
}
|
||||
|
||||
func selectBackend(name string) (Backend, error) {
|
||||
key := strings.ToLower(strings.TrimSpace(name))
|
||||
if key == "" {
|
||||
key = defaultBackendName
|
||||
}
|
||||
if backend, ok := backendRegistry[key]; ok {
|
||||
return backend, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unsupported backend %q", name)
|
||||
}
|
||||
|
||||
func envFlagEnabled(key string) bool {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
val = strings.TrimSpace(strings.ToLower(val))
|
||||
switch val {
|
||||
case "", "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func parseBoolFlag(val string, defaultValue bool) bool {
|
||||
val = strings.TrimSpace(strings.ToLower(val))
|
||||
switch val {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
case "0", "false", "no", "off":
|
||||
return false
|
||||
default:
|
||||
return defaultValue
|
||||
}
|
||||
}
|
||||
|
||||
// envFlagDefaultTrue returns true unless the env var is explicitly set to false/0/no/off.
|
||||
func envFlagDefaultTrue(key string) bool {
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
return parseBoolFlag(val, true)
|
||||
}
|
||||
|
||||
func validateAgentName(name string) error {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fmt.Errorf("agent name is empty")
|
||||
}
|
||||
for _, r := range name {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
case r >= 'A' && r <= 'Z':
|
||||
case r >= '0' && r <= '9':
|
||||
case r == '-', r == '_':
|
||||
default:
|
||||
return fmt.Errorf("agent name %q contains invalid character %q", name, r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
trimmed := bytes.TrimSpace(data)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, fmt.Errorf("parallel config is empty")
|
||||
}
|
||||
|
||||
tasks := strings.Split(string(trimmed), "---TASK---")
|
||||
var cfg ParallelConfig
|
||||
seen := make(map[string]struct{})
|
||||
|
||||
taskIndex := 0
|
||||
for _, taskBlock := range tasks {
|
||||
taskBlock = strings.TrimSpace(taskBlock)
|
||||
if taskBlock == "" {
|
||||
continue
|
||||
}
|
||||
taskIndex++
|
||||
|
||||
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
|
||||
}
|
||||
|
||||
meta := strings.TrimSpace(parts[0])
|
||||
content := strings.TrimSpace(parts[1])
|
||||
|
||||
task := TaskSpec{WorkDir: defaultWorkdir}
|
||||
agentSpecified := false
|
||||
for _, line := range strings.Split(meta, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
kv := strings.SplitN(line, ":", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(kv[0])
|
||||
value := strings.TrimSpace(kv[1])
|
||||
|
||||
switch key {
|
||||
case "id":
|
||||
task.ID = value
|
||||
case "workdir":
|
||||
// Validate workdir: "-" is not a valid directory
|
||||
if value == "-" {
|
||||
return nil, fmt.Errorf("task block #%d has invalid workdir: '-' is not a valid directory path", taskIndex)
|
||||
}
|
||||
task.WorkDir = value
|
||||
case "session_id":
|
||||
task.SessionID = value
|
||||
task.Mode = "resume"
|
||||
case "backend":
|
||||
task.Backend = value
|
||||
case "model":
|
||||
task.Model = value
|
||||
case "reasoning_effort":
|
||||
task.ReasoningEffort = value
|
||||
case "agent":
|
||||
agentSpecified = true
|
||||
task.Agent = value
|
||||
case "skip_permissions", "skip-permissions":
|
||||
if value == "" {
|
||||
task.SkipPermissions = true
|
||||
continue
|
||||
}
|
||||
task.SkipPermissions = parseBoolFlag(value, false)
|
||||
case "dependencies":
|
||||
for _, dep := range strings.Split(value, ",") {
|
||||
dep = strings.TrimSpace(dep)
|
||||
if dep != "" {
|
||||
task.Dependencies = append(task.Dependencies, dep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if task.Mode == "" {
|
||||
task.Mode = "new"
|
||||
}
|
||||
|
||||
if agentSpecified {
|
||||
if strings.TrimSpace(task.Agent) == "" {
|
||||
return nil, fmt.Errorf("task block #%d has empty agent field", taskIndex)
|
||||
}
|
||||
if err := validateAgentName(task.Agent); err != nil {
|
||||
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
|
||||
}
|
||||
backend, model, promptFile, reasoning, _ := resolveAgentConfig(task.Agent)
|
||||
if task.Backend == "" {
|
||||
task.Backend = backend
|
||||
}
|
||||
if task.Model == "" {
|
||||
task.Model = model
|
||||
}
|
||||
if task.ReasoningEffort == "" {
|
||||
task.ReasoningEffort = reasoning
|
||||
}
|
||||
task.PromptFile = promptFile
|
||||
}
|
||||
|
||||
if task.ID == "" {
|
||||
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
|
||||
}
|
||||
if content == "" {
|
||||
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
|
||||
}
|
||||
if task.Mode == "resume" && strings.TrimSpace(task.SessionID) == "" {
|
||||
return nil, fmt.Errorf("task block #%d (%q) has empty session_id", taskIndex, task.ID)
|
||||
}
|
||||
if _, exists := seen[task.ID]; exists {
|
||||
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
|
||||
}
|
||||
|
||||
task.Task = content
|
||||
cfg.Tasks = append(cfg.Tasks, task)
|
||||
seen[task.ID] = struct{}{}
|
||||
}
|
||||
|
||||
if len(cfg.Tasks) == 0 {
|
||||
return nil, fmt.Errorf("no tasks found")
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func parseArgs() (*Config, error) {
|
||||
args := os.Args[1:]
|
||||
if len(args) == 0 {
|
||||
return nil, fmt.Errorf("task required")
|
||||
}
|
||||
|
||||
backendName := defaultBackendName
|
||||
model := ""
|
||||
reasoningEffort := ""
|
||||
agentName := ""
|
||||
promptFile := ""
|
||||
promptFileExplicit := false
|
||||
yolo := false
|
||||
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
|
||||
filtered := make([]string, 0, len(args))
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--agent":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("--agent flag requires a value")
|
||||
}
|
||||
value := strings.TrimSpace(args[i+1])
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--agent flag requires a value")
|
||||
}
|
||||
if err := validateAgentName(value); err != nil {
|
||||
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
|
||||
}
|
||||
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, resolvedYolo := resolveAgentConfig(value)
|
||||
backendName = resolvedBackend
|
||||
model = resolvedModel
|
||||
if !promptFileExplicit {
|
||||
promptFile = resolvedPromptFile
|
||||
}
|
||||
if reasoningEffort == "" {
|
||||
reasoningEffort = resolvedReasoning
|
||||
}
|
||||
yolo = resolvedYolo
|
||||
agentName = value
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--agent="):
|
||||
value := strings.TrimSpace(strings.TrimPrefix(arg, "--agent="))
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--agent flag requires a value")
|
||||
}
|
||||
if err := validateAgentName(value); err != nil {
|
||||
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
|
||||
}
|
||||
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, resolvedYolo := resolveAgentConfig(value)
|
||||
backendName = resolvedBackend
|
||||
model = resolvedModel
|
||||
if !promptFileExplicit {
|
||||
promptFile = resolvedPromptFile
|
||||
}
|
||||
if reasoningEffort == "" {
|
||||
reasoningEffort = resolvedReasoning
|
||||
}
|
||||
yolo = resolvedYolo
|
||||
agentName = value
|
||||
continue
|
||||
case arg == "--prompt-file":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("--prompt-file flag requires a value")
|
||||
}
|
||||
value := strings.TrimSpace(args[i+1])
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--prompt-file flag requires a value")
|
||||
}
|
||||
promptFile = value
|
||||
promptFileExplicit = true
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--prompt-file="):
|
||||
value := strings.TrimSpace(strings.TrimPrefix(arg, "--prompt-file="))
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--prompt-file flag requires a value")
|
||||
}
|
||||
promptFile = value
|
||||
promptFileExplicit = true
|
||||
continue
|
||||
case arg == "--backend":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("--backend flag requires a value")
|
||||
}
|
||||
backendName = args[i+1]
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--backend="):
|
||||
value := strings.TrimPrefix(arg, "--backend=")
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--backend flag requires a value")
|
||||
}
|
||||
backendName = value
|
||||
continue
|
||||
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
|
||||
skipPermissions = true
|
||||
continue
|
||||
case arg == "--model":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("--model flag requires a value")
|
||||
}
|
||||
model = args[i+1]
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--model="):
|
||||
value := strings.TrimPrefix(arg, "--model=")
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--model flag requires a value")
|
||||
}
|
||||
model = value
|
||||
continue
|
||||
case arg == "--reasoning-effort":
|
||||
if i+1 >= len(args) {
|
||||
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
|
||||
}
|
||||
value := strings.TrimSpace(args[i+1])
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
|
||||
}
|
||||
reasoningEffort = value
|
||||
i++
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--reasoning-effort="):
|
||||
value := strings.TrimSpace(strings.TrimPrefix(arg, "--reasoning-effort="))
|
||||
if value == "" {
|
||||
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
|
||||
}
|
||||
reasoningEffort = value
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--skip-permissions="):
|
||||
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
|
||||
continue
|
||||
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
|
||||
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, arg)
|
||||
}
|
||||
|
||||
if len(filtered) == 0 {
|
||||
return nil, fmt.Errorf("task required")
|
||||
}
|
||||
args = filtered
|
||||
|
||||
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, Agent: agentName, PromptFile: promptFile, PromptFileExplicit: promptFileExplicit, SkipPermissions: skipPermissions, Yolo: yolo, Model: strings.TrimSpace(model), ReasoningEffort: strings.TrimSpace(reasoningEffort)}
|
||||
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
|
||||
|
||||
if args[0] == "resume" {
|
||||
if len(args) < 3 {
|
||||
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
|
||||
}
|
||||
cfg.Mode = "resume"
|
||||
cfg.SessionID = strings.TrimSpace(args[1])
|
||||
if cfg.SessionID == "" {
|
||||
return nil, fmt.Errorf("resume mode requires non-empty session_id")
|
||||
}
|
||||
cfg.Task = args[2]
|
||||
cfg.ExplicitStdin = (args[2] == "-")
|
||||
if len(args) > 3 {
|
||||
// Validate workdir: "-" is not a valid directory
|
||||
if args[3] == "-" {
|
||||
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
|
||||
}
|
||||
cfg.WorkDir = args[3]
|
||||
}
|
||||
} else {
|
||||
cfg.Mode = "new"
|
||||
cfg.Task = args[0]
|
||||
cfg.ExplicitStdin = (args[0] == "-")
|
||||
if len(args) > 1 {
|
||||
// Validate workdir: "-" is not a valid directory
|
||||
if args[1] == "-" {
|
||||
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
|
||||
}
|
||||
cfg.WorkDir = args[1]
|
||||
}
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
const maxParallelWorkersLimit = 100
|
||||
|
||||
func resolveMaxParallelWorkers() int {
|
||||
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
|
||||
if raw == "" {
|
||||
return 0
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
logWarn(fmt.Sprintf("Invalid CODEAGENT_MAX_PARALLEL_WORKERS=%q, falling back to unlimited", raw))
|
||||
return 0
|
||||
}
|
||||
|
||||
if value > maxParallelWorkersLimit {
|
||||
logWarn(fmt.Sprintf("CODEAGENT_MAX_PARALLEL_WORKERS=%d exceeds limit, capping at %d", value, maxParallelWorkersLimit))
|
||||
return maxParallelWorkersLimit
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
1397
codeagent-wrapper/executor.go
Normal file
1397
codeagent-wrapper/executor.go
Normal file
File diff suppressed because it is too large
Load Diff
1477
codeagent-wrapper/executor_concurrent_test.go
Normal file
1477
codeagent-wrapper/executor_concurrent_test.go
Normal file
File diff suppressed because it is too large
Load Diff
72
codeagent-wrapper/filter.go
Normal file
72
codeagent-wrapper/filter.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// geminiNoisePatterns contains stderr patterns to filter for gemini backend
|
||||
var geminiNoisePatterns = []string{
|
||||
"[STARTUP]",
|
||||
"Session cleanup disabled",
|
||||
"Warning:",
|
||||
"(node:",
|
||||
"(Use `node --trace-warnings",
|
||||
"Loaded cached credentials",
|
||||
"Loading extension:",
|
||||
"YOLO mode is enabled",
|
||||
}
|
||||
|
||||
// codexNoisePatterns contains stderr patterns to filter for codex backend
|
||||
var codexNoisePatterns = []string{
|
||||
"ERROR codex_core::codex: needs_follow_up:",
|
||||
"ERROR codex_core::skills::loader:",
|
||||
}
|
||||
|
||||
// filteringWriter wraps an io.Writer and filters out lines matching patterns
|
||||
type filteringWriter struct {
|
||||
w io.Writer
|
||||
patterns []string
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func newFilteringWriter(w io.Writer, patterns []string) *filteringWriter {
|
||||
return &filteringWriter{w: w, patterns: patterns}
|
||||
}
|
||||
|
||||
func (f *filteringWriter) Write(p []byte) (n int, err error) {
|
||||
f.buf.Write(p)
|
||||
for {
|
||||
line, err := f.buf.ReadString('\n')
|
||||
if err != nil {
|
||||
// incomplete line, put it back
|
||||
f.buf.WriteString(line)
|
||||
break
|
||||
}
|
||||
if !f.shouldFilter(line) {
|
||||
f.w.Write([]byte(line))
|
||||
}
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (f *filteringWriter) shouldFilter(line string) bool {
|
||||
for _, pattern := range f.patterns {
|
||||
if strings.Contains(line, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Flush writes any remaining buffered content
|
||||
func (f *filteringWriter) Flush() {
|
||||
if f.buf.Len() > 0 {
|
||||
remaining := f.buf.String()
|
||||
if !f.shouldFilter(remaining) {
|
||||
f.w.Write([]byte(remaining))
|
||||
}
|
||||
f.buf.Reset()
|
||||
}
|
||||
}
|
||||
73
codeagent-wrapper/filter_test.go
Normal file
73
codeagent-wrapper/filter_test.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFilteringWriter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
patterns []string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "filter STARTUP lines",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "[STARTUP] Recording metric\nHello World\n[STARTUP] Another line\n",
|
||||
want: "Hello World\n",
|
||||
},
|
||||
{
|
||||
name: "filter Warning lines",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "Warning: something bad\nActual output\n",
|
||||
want: "Actual output\n",
|
||||
},
|
||||
{
|
||||
name: "filter multiple patterns",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "YOLO mode is enabled\nSession cleanup disabled\nReal content\nLoading extension: foo\n",
|
||||
want: "Real content\n",
|
||||
},
|
||||
{
|
||||
name: "no filtering needed",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "Line 1\nLine 2\nLine 3\n",
|
||||
want: "Line 1\nLine 2\nLine 3\n",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
patterns: geminiNoisePatterns,
|
||||
input: "",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fw := newFilteringWriter(&buf, tt.patterns)
|
||||
fw.Write([]byte(tt.input))
|
||||
fw.Flush()
|
||||
|
||||
if got := buf.String(); got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilteringWriterPartialLines(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
fw := newFilteringWriter(&buf, geminiNoisePatterns)
|
||||
|
||||
// Write partial line
|
||||
fw.Write([]byte("Hello "))
|
||||
fw.Write([]byte("World\n"))
|
||||
fw.Flush()
|
||||
|
||||
if got := buf.String(); got != "Hello World\n" {
|
||||
t.Errorf("got %q, want %q", got, "Hello World\n")
|
||||
}
|
||||
}
|
||||
3
codeagent-wrapper/go.mod
Normal file
3
codeagent-wrapper/go.mod
Normal file
@@ -0,0 +1,3 @@
|
||||
module codeagent-wrapper
|
||||
|
||||
go 1.21
|
||||
38
codeagent-wrapper/log_writer_limit_test.go
Normal file
38
codeagent-wrapper/log_writer_limit_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLogWriterWriteLimitsBuffer(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger error: %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
defer closeLogger()
|
||||
|
||||
lw := newLogWriter("P:", 10)
|
||||
_, _ = lw.Write([]byte(strings.Repeat("a", 100)))
|
||||
|
||||
if lw.buf.Len() != 10 {
|
||||
t.Fatalf("logWriter buffer len=%d, want %d", lw.buf.Len(), 10)
|
||||
}
|
||||
if !lw.dropped {
|
||||
t.Fatalf("expected logWriter to drop overlong line bytes")
|
||||
}
|
||||
|
||||
lw.Flush()
|
||||
logger.Flush()
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile error: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), "P:aaaaaaa...") {
|
||||
t.Fatalf("log output missing truncated entry, got %q", string(data))
|
||||
}
|
||||
}
|
||||
663
codeagent-wrapper/logger.go
Normal file
663
codeagent-wrapper/logger.go
Normal file
@@ -0,0 +1,663 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Logger writes log messages asynchronously to a temp file.
|
||||
// It is intentionally minimal: a buffered channel + single worker goroutine
|
||||
// to avoid contention while keeping ordering guarantees.
|
||||
type Logger struct {
|
||||
path string
|
||||
file *os.File
|
||||
writer *bufio.Writer
|
||||
ch chan logEntry
|
||||
flushReq chan chan struct{}
|
||||
done chan struct{}
|
||||
closed atomic.Bool
|
||||
closeOnce sync.Once
|
||||
workerWG sync.WaitGroup
|
||||
pendingWG sync.WaitGroup
|
||||
flushMu sync.Mutex
|
||||
workerErr error
|
||||
errorEntries []string // Cache of recent ERROR/WARN entries
|
||||
errorMu sync.Mutex
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
msg string
|
||||
isError bool // true for ERROR or WARN levels
|
||||
}
|
||||
|
||||
// CleanupStats captures the outcome of a cleanupOldLogs run.
|
||||
type CleanupStats struct {
|
||||
Scanned int
|
||||
Deleted int
|
||||
Kept int
|
||||
Errors int
|
||||
DeletedFiles []string
|
||||
KeptFiles []string
|
||||
}
|
||||
|
||||
var (
|
||||
processRunningCheck = isProcessRunning
|
||||
processStartTimeFn = getProcessStartTime
|
||||
removeLogFileFn = os.Remove
|
||||
globLogFiles = filepath.Glob
|
||||
fileStatFn = os.Lstat // Use Lstat to detect symlinks
|
||||
evalSymlinksFn = filepath.EvalSymlinks
|
||||
)
|
||||
|
||||
const maxLogSuffixLen = 64
|
||||
|
||||
var logSuffixCounter atomic.Uint64
|
||||
|
||||
// NewLogger creates the async logger and starts the worker goroutine.
|
||||
// The log file is created under os.TempDir() using the required naming scheme.
|
||||
func NewLogger() (*Logger, error) {
|
||||
return NewLoggerWithSuffix("")
|
||||
}
|
||||
|
||||
// NewLoggerWithSuffix creates a logger with an optional suffix in the filename.
|
||||
// Useful for tests that need isolated log files within the same process.
|
||||
func NewLoggerWithSuffix(suffix string) (*Logger, error) {
|
||||
pid := os.Getpid()
|
||||
filename := fmt.Sprintf("%s-%d", primaryLogPrefix(), pid)
|
||||
var safeSuffix string
|
||||
if suffix != "" {
|
||||
safeSuffix = sanitizeLogSuffix(suffix)
|
||||
}
|
||||
if safeSuffix != "" {
|
||||
filename += "-" + safeSuffix
|
||||
}
|
||||
filename += ".log"
|
||||
|
||||
path := filepath.Clean(filepath.Join(os.TempDir(), filename))
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
l := &Logger{
|
||||
path: path,
|
||||
file: f,
|
||||
writer: bufio.NewWriterSize(f, 4096),
|
||||
ch: make(chan logEntry, 1000),
|
||||
flushReq: make(chan chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
l.workerWG.Add(1)
|
||||
go l.run()
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func sanitizeLogSuffix(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return fallbackLogSuffix()
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
changed := false
|
||||
for _, r := range trimmed {
|
||||
if isSafeLogRune(r) {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
changed = true
|
||||
b.WriteByte('-')
|
||||
}
|
||||
if b.Len() >= maxLogSuffixLen {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sanitized := strings.Trim(b.String(), "-.")
|
||||
if sanitized != b.String() {
|
||||
changed = true // Mark if trim removed any characters
|
||||
}
|
||||
if sanitized == "" {
|
||||
return fallbackLogSuffix()
|
||||
}
|
||||
|
||||
if changed || len(sanitized) > maxLogSuffixLen {
|
||||
hash := crc32.ChecksumIEEE([]byte(trimmed))
|
||||
hashStr := fmt.Sprintf("%x", hash)
|
||||
|
||||
maxPrefix := maxLogSuffixLen - len(hashStr) - 1
|
||||
if maxPrefix < 1 {
|
||||
maxPrefix = 1
|
||||
}
|
||||
if len(sanitized) > maxPrefix {
|
||||
sanitized = sanitized[:maxPrefix]
|
||||
}
|
||||
|
||||
sanitized = fmt.Sprintf("%s-%s", sanitized, hashStr)
|
||||
}
|
||||
|
||||
return sanitized
|
||||
}
|
||||
|
||||
func fallbackLogSuffix() string {
|
||||
next := logSuffixCounter.Add(1)
|
||||
return fmt.Sprintf("task-%d", next)
|
||||
}
|
||||
|
||||
func isSafeLogRune(r rune) bool {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
return true
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return true
|
||||
case r >= '0' && r <= '9':
|
||||
return true
|
||||
case r == '-', r == '_', r == '.':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Path returns the underlying log file path (useful for tests/inspection).
|
||||
func (l *Logger) Path() string {
|
||||
if l == nil {
|
||||
return ""
|
||||
}
|
||||
return l.path
|
||||
}
|
||||
|
||||
// Info logs at INFO level.
|
||||
func (l *Logger) Info(msg string) { l.log("INFO", msg) }
|
||||
|
||||
// Warn logs at WARN level.
|
||||
func (l *Logger) Warn(msg string) { l.log("WARN", msg) }
|
||||
|
||||
// Debug logs at DEBUG level.
|
||||
func (l *Logger) Debug(msg string) { l.log("DEBUG", msg) }
|
||||
|
||||
// Error logs at ERROR level.
|
||||
func (l *Logger) Error(msg string) { l.log("ERROR", msg) }
|
||||
|
||||
// Close signals the worker to flush and close the log file.
|
||||
// The log file is NOT removed, allowing inspection after program exit.
|
||||
// It is safe to call multiple times.
|
||||
// Waits up to CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS (default: 5000) for shutdown; set to 0 to wait indefinitely.
|
||||
// Returns an error if shutdown doesn't complete within the timeout.
|
||||
func (l *Logger) Close() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var closeErr error
|
||||
|
||||
l.closeOnce.Do(func() {
|
||||
l.closed.Store(true)
|
||||
close(l.done)
|
||||
|
||||
timeout := loggerCloseTimeout()
|
||||
workerDone := make(chan struct{})
|
||||
go func() {
|
||||
l.workerWG.Wait()
|
||||
close(workerDone)
|
||||
}()
|
||||
|
||||
if timeout > 0 {
|
||||
select {
|
||||
case <-workerDone:
|
||||
// Worker stopped gracefully
|
||||
case <-time.After(timeout):
|
||||
closeErr = fmt.Errorf("logger worker timeout during close")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
<-workerDone
|
||||
}
|
||||
|
||||
if l.workerErr != nil && closeErr == nil {
|
||||
closeErr = l.workerErr
|
||||
}
|
||||
})
|
||||
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func loggerCloseTimeout() time.Duration {
|
||||
const defaultTimeout = 5 * time.Second
|
||||
|
||||
raw := strings.TrimSpace(os.Getenv("CODEAGENT_LOGGER_CLOSE_TIMEOUT_MS"))
|
||||
if raw == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
ms, err := strconv.Atoi(raw)
|
||||
if err != nil {
|
||||
return defaultTimeout
|
||||
}
|
||||
if ms <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
// RemoveLogFile removes the log file. Should only be called after Close().
|
||||
func (l *Logger) RemoveLogFile() error {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
return os.Remove(l.path)
|
||||
}
|
||||
|
||||
// ExtractRecentErrors returns the most recent ERROR and WARN entries from memory cache.
|
||||
// Returns up to maxEntries entries in chronological order.
|
||||
func (l *Logger) ExtractRecentErrors(maxEntries int) []string {
|
||||
if l == nil || maxEntries <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
l.errorMu.Lock()
|
||||
defer l.errorMu.Unlock()
|
||||
|
||||
if len(l.errorEntries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return last N entries
|
||||
start := 0
|
||||
if len(l.errorEntries) > maxEntries {
|
||||
start = len(l.errorEntries) - maxEntries
|
||||
}
|
||||
|
||||
result := make([]string, len(l.errorEntries)-start)
|
||||
copy(result, l.errorEntries[start:])
|
||||
return result
|
||||
}
|
||||
|
||||
// Flush waits for all pending log entries to be written. Primarily for tests.
|
||||
// Returns after a 5-second timeout to prevent indefinite blocking.
|
||||
func (l *Logger) Flush() {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
|
||||
l.flushMu.Lock()
|
||||
defer l.flushMu.Unlock()
|
||||
|
||||
// Wait for pending entries with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
l.pendingWG.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// All pending entries processed
|
||||
case <-ctx.Done():
|
||||
// Timeout - return without full flush
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger writer flush
|
||||
flushDone := make(chan struct{})
|
||||
select {
|
||||
case l.flushReq <- flushDone:
|
||||
// Wait for flush to complete
|
||||
select {
|
||||
case <-flushDone:
|
||||
// Flush completed
|
||||
case <-time.After(1 * time.Second):
|
||||
// Flush timeout
|
||||
}
|
||||
case <-l.done:
|
||||
// Logger is closing
|
||||
case <-time.After(1 * time.Second):
|
||||
// Timeout sending flush request
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level, msg string) {
|
||||
if l == nil {
|
||||
return
|
||||
}
|
||||
if l.closed.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
isError := level == "WARN" || level == "ERROR"
|
||||
entry := logEntry{msg: msg, isError: isError}
|
||||
l.flushMu.Lock()
|
||||
l.pendingWG.Add(1)
|
||||
l.flushMu.Unlock()
|
||||
|
||||
select {
|
||||
case l.ch <- entry:
|
||||
// Successfully sent to channel
|
||||
case <-l.done:
|
||||
// Logger is closing, drop this entry
|
||||
l.pendingWG.Done()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) run() {
|
||||
defer l.workerWG.Done()
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
writeEntry := func(entry logEntry) {
|
||||
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
|
||||
fmt.Fprintf(l.writer, "[%s] %s\n", timestamp, entry.msg)
|
||||
|
||||
// Cache error/warn entries in memory for fast extraction
|
||||
if entry.isError {
|
||||
l.errorMu.Lock()
|
||||
l.errorEntries = append(l.errorEntries, entry.msg)
|
||||
if len(l.errorEntries) > 100 { // Keep last 100
|
||||
l.errorEntries = l.errorEntries[1:]
|
||||
}
|
||||
l.errorMu.Unlock()
|
||||
}
|
||||
|
||||
l.pendingWG.Done()
|
||||
}
|
||||
|
||||
finalize := func() {
|
||||
if err := l.writer.Flush(); err != nil && l.workerErr == nil {
|
||||
l.workerErr = err
|
||||
}
|
||||
if err := l.file.Sync(); err != nil && l.workerErr == nil {
|
||||
l.workerErr = err
|
||||
}
|
||||
if err := l.file.Close(); err != nil && l.workerErr == nil {
|
||||
l.workerErr = err
|
||||
}
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-l.ch:
|
||||
if !ok {
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
writeEntry(entry)
|
||||
|
||||
case <-ticker.C:
|
||||
_ = l.writer.Flush()
|
||||
|
||||
case flushDone := <-l.flushReq:
|
||||
// Explicit flush request - flush writer and sync to disk
|
||||
_ = l.writer.Flush()
|
||||
_ = l.file.Sync()
|
||||
close(flushDone)
|
||||
|
||||
case <-l.done:
|
||||
for {
|
||||
select {
|
||||
case entry, ok := <-l.ch:
|
||||
if !ok {
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
writeEntry(entry)
|
||||
default:
|
||||
finalize()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanupOldLogs scans os.TempDir() for wrapper log files and removes those
|
||||
// whose owning process is no longer running (i.e., orphaned logs).
|
||||
// It includes safety checks for:
|
||||
// - PID reuse: Compares file modification time with process start time
|
||||
// - Symlink attacks: Ensures files are within TempDir and not symlinks
|
||||
func cleanupOldLogs() (CleanupStats, error) {
|
||||
var stats CleanupStats
|
||||
tempDir := os.TempDir()
|
||||
|
||||
prefixes := logPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{defaultWrapperName}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var matches []string
|
||||
for _, prefix := range prefixes {
|
||||
pattern := filepath.Join(tempDir, fmt.Sprintf("%s-*.log", prefix))
|
||||
found, err := globLogFiles(pattern)
|
||||
if err != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs: failed to list logs: %v", err))
|
||||
return stats, fmt.Errorf("cleanupOldLogs: %w", err)
|
||||
}
|
||||
for _, path := range found {
|
||||
if _, ok := seen[path]; ok {
|
||||
continue
|
||||
}
|
||||
seen[path] = struct{}{}
|
||||
matches = append(matches, path)
|
||||
}
|
||||
}
|
||||
|
||||
var removeErr error
|
||||
|
||||
for _, path := range matches {
|
||||
stats.Scanned++
|
||||
filename := filepath.Base(path)
|
||||
|
||||
// Security check: Verify file is not a symlink and is within tempDir
|
||||
if shouldSkipFile, reason := isUnsafeFile(path, tempDir); shouldSkipFile {
|
||||
stats.Kept++
|
||||
stats.KeptFiles = append(stats.KeptFiles, filename)
|
||||
if reason != "" {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs: skipping %s: %s", filename, reason))
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
pid, ok := parsePIDFromLog(path)
|
||||
if !ok {
|
||||
stats.Kept++
|
||||
stats.KeptFiles = append(stats.KeptFiles, filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if process is running
|
||||
if !processRunningCheck(pid) {
|
||||
// Process not running, safe to delete
|
||||
if err := removeLogFileFn(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
// File already deleted by another process, don't count as success
|
||||
stats.Kept++
|
||||
stats.KeptFiles = append(stats.KeptFiles, filename+" (already deleted)")
|
||||
continue
|
||||
}
|
||||
stats.Errors++
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs: failed to remove %s: %v", filename, err))
|
||||
removeErr = errors.Join(removeErr, fmt.Errorf("failed to remove %s: %w", filename, err))
|
||||
continue
|
||||
}
|
||||
stats.Deleted++
|
||||
stats.DeletedFiles = append(stats.DeletedFiles, filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process is running, check for PID reuse
|
||||
if isPIDReused(path, pid) {
|
||||
// PID was reused, the log file is orphaned
|
||||
if err := removeLogFileFn(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
stats.Kept++
|
||||
stats.KeptFiles = append(stats.KeptFiles, filename+" (already deleted)")
|
||||
continue
|
||||
}
|
||||
stats.Errors++
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs: failed to remove %s (PID reused): %v", filename, err))
|
||||
removeErr = errors.Join(removeErr, fmt.Errorf("failed to remove %s: %w", filename, err))
|
||||
continue
|
||||
}
|
||||
stats.Deleted++
|
||||
stats.DeletedFiles = append(stats.DeletedFiles, filename)
|
||||
continue
|
||||
}
|
||||
|
||||
// Process is running and owns this log file
|
||||
stats.Kept++
|
||||
stats.KeptFiles = append(stats.KeptFiles, filename)
|
||||
}
|
||||
|
||||
if removeErr != nil {
|
||||
return stats, fmt.Errorf("cleanupOldLogs: %w", removeErr)
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// isUnsafeFile checks if a file is unsafe to delete (symlink or outside tempDir).
|
||||
// Returns (true, reason) if the file should be skipped.
|
||||
func isUnsafeFile(path string, tempDir string) (bool, string) {
|
||||
// Check if file is a symlink
|
||||
info, err := fileStatFn(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return true, "" // File disappeared, skip silently
|
||||
}
|
||||
return true, fmt.Sprintf("stat failed: %v", err)
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
return true, "refusing to delete symlink"
|
||||
}
|
||||
|
||||
// Resolve any path traversal and verify it's within tempDir
|
||||
resolvedPath, err := evalSymlinksFn(path)
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("path resolution failed: %v", err)
|
||||
}
|
||||
|
||||
// Get absolute path of tempDir
|
||||
absTempDir, err := filepath.Abs(tempDir)
|
||||
if err != nil {
|
||||
return true, fmt.Sprintf("tempDir resolution failed: %v", err)
|
||||
}
|
||||
|
||||
// Ensure resolved path is within tempDir
|
||||
relPath, err := filepath.Rel(absTempDir, resolvedPath)
|
||||
if err != nil || strings.HasPrefix(relPath, "..") {
|
||||
return true, "file is outside tempDir"
|
||||
}
|
||||
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// isPIDReused checks if a PID has been reused by comparing file modification time
|
||||
// with process start time. Returns true if the log file was created by a different
|
||||
// process that previously had the same PID.
|
||||
func isPIDReused(logPath string, pid int) bool {
|
||||
// Get file modification time (when log was last written)
|
||||
info, err := fileStatFn(logPath)
|
||||
if err != nil {
|
||||
// If we can't stat the file, be conservative and keep it
|
||||
return false
|
||||
}
|
||||
fileModTime := info.ModTime()
|
||||
|
||||
// Get process start time
|
||||
procStartTime := processStartTimeFn(pid)
|
||||
if procStartTime.IsZero() {
|
||||
// Can't determine process start time
|
||||
// Check if file is very old (>7 days), likely from a dead process
|
||||
if time.Since(fileModTime) > 7*24*time.Hour {
|
||||
return true // File is old enough to be from a different process
|
||||
}
|
||||
return false // Be conservative for recent files
|
||||
}
|
||||
|
||||
// If the log file was modified before the process started, PID was reused
|
||||
// Add a small buffer (1 second) to account for clock skew and file system timing
|
||||
return fileModTime.Add(1 * time.Second).Before(procStartTime)
|
||||
}
|
||||
|
||||
func parsePIDFromLog(path string) (int, bool) {
|
||||
name := filepath.Base(path)
|
||||
prefixes := logPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
prefixes = []string{defaultWrapperName}
|
||||
}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
prefixWithDash := fmt.Sprintf("%s-", prefix)
|
||||
if !strings.HasPrefix(name, prefixWithDash) || !strings.HasSuffix(name, ".log") {
|
||||
continue
|
||||
}
|
||||
|
||||
core := strings.TrimSuffix(strings.TrimPrefix(name, prefixWithDash), ".log")
|
||||
if core == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
pidPart := core
|
||||
if idx := strings.IndexRune(core, '-'); idx != -1 {
|
||||
pidPart = core[:idx]
|
||||
}
|
||||
|
||||
if pidPart == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
pid, err := strconv.Atoi(pidPart)
|
||||
if err != nil || pid <= 0 {
|
||||
continue
|
||||
}
|
||||
return pid, true
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func logConcurrencyPlanning(limit, total int) {
|
||||
logger := activeLogger()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
logger.Info(fmt.Sprintf("parallel: worker_limit=%s total_tasks=%d", renderWorkerLimit(limit), total))
|
||||
}
|
||||
|
||||
func logConcurrencyState(event, taskID string, active, limit int) {
|
||||
logger := activeLogger()
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
logger.Debug(fmt.Sprintf("parallel: %s task=%s active=%d limit=%s", event, taskID, active, renderWorkerLimit(limit)))
|
||||
}
|
||||
|
||||
func renderWorkerLimit(limit int) string {
|
||||
if limit <= 0 {
|
||||
return "unbounded"
|
||||
}
|
||||
return strconv.Itoa(limit)
|
||||
}
|
||||
158
codeagent-wrapper/logger_additional_coverage_test.go
Normal file
158
codeagent-wrapper/logger_additional_coverage_test.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerNilReceiverNoop(t *testing.T) {
|
||||
var logger *Logger
|
||||
logger.Info("info")
|
||||
logger.Warn("warn")
|
||||
logger.Debug("debug")
|
||||
logger.Error("error")
|
||||
logger.Flush()
|
||||
if err := logger.Close(); err != nil {
|
||||
t.Fatalf("Close() on nil logger should return nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerConcurrencyLogHelpers(t *testing.T) {
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLoggerWithSuffix("concurrency")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix error: %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
defer closeLogger()
|
||||
|
||||
logConcurrencyPlanning(0, 2)
|
||||
logConcurrencyPlanning(3, 2)
|
||||
logConcurrencyState("start", "task-1", 1, 0)
|
||||
logConcurrencyState("done", "task-1", 0, 3)
|
||||
logger.Flush()
|
||||
|
||||
data, err := os.ReadFile(logger.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read log file: %v", err)
|
||||
}
|
||||
output := string(data)
|
||||
|
||||
checks := []string{
|
||||
"parallel: worker_limit=unbounded total_tasks=2",
|
||||
"parallel: worker_limit=3 total_tasks=2",
|
||||
"parallel: start task=task-1 active=1 limit=unbounded",
|
||||
"parallel: done task=task-1 active=0 limit=3",
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(output, c) {
|
||||
t.Fatalf("log output missing %q, got: %s", c, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerConcurrencyLogHelpersNoopWithoutActiveLogger(t *testing.T) {
|
||||
_ = closeLogger()
|
||||
logConcurrencyPlanning(1, 1)
|
||||
logConcurrencyState("start", "task-1", 0, 1)
|
||||
}
|
||||
|
||||
func TestLoggerCleanupOldLogsSkipsUnsafeAndHandlesAlreadyDeleted(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
unsafePath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", primaryLogPrefix(), 222))
|
||||
orphanPath := createTempLog(t, tempDir, fmt.Sprintf("%s-%d.log", primaryLogPrefix(), 111))
|
||||
|
||||
stubFileStat(t, func(path string) (os.FileInfo, error) {
|
||||
if path == unsafePath {
|
||||
return fakeFileInfo{mode: os.ModeSymlink}, nil
|
||||
}
|
||||
return os.Lstat(path)
|
||||
})
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
if pid == 111 {
|
||||
_ = os.Remove(orphanPath)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
stats, err := cleanupOldLogs()
|
||||
if err != nil {
|
||||
t.Fatalf("cleanupOldLogs() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if stats.Scanned != 2 {
|
||||
t.Fatalf("scanned = %d, want %d", stats.Scanned, 2)
|
||||
}
|
||||
if stats.Deleted != 0 {
|
||||
t.Fatalf("deleted = %d, want %d", stats.Deleted, 0)
|
||||
}
|
||||
if stats.Kept != 2 {
|
||||
t.Fatalf("kept = %d, want %d", stats.Kept, 2)
|
||||
}
|
||||
if stats.Errors != 0 {
|
||||
t.Fatalf("errors = %d, want %d", stats.Errors, 0)
|
||||
}
|
||||
|
||||
hasSkip := false
|
||||
hasAlreadyDeleted := false
|
||||
for _, name := range stats.KeptFiles {
|
||||
if strings.Contains(name, "already deleted") {
|
||||
hasAlreadyDeleted = true
|
||||
}
|
||||
if strings.Contains(name, filepath.Base(unsafePath)) {
|
||||
hasSkip = true
|
||||
}
|
||||
}
|
||||
if !hasSkip {
|
||||
t.Fatalf("expected kept files to include unsafe log %q, got %+v", filepath.Base(unsafePath), stats.KeptFiles)
|
||||
}
|
||||
if !hasAlreadyDeleted {
|
||||
t.Fatalf("expected kept files to include already deleted marker, got %+v", stats.KeptFiles)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerIsUnsafeFileErrorPaths(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("stat ErrNotExist", func(t *testing.T) {
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) {
|
||||
return nil, os.ErrNotExist
|
||||
})
|
||||
|
||||
unsafe, reason := isUnsafeFile("missing.log", tempDir)
|
||||
if !unsafe || reason != "" {
|
||||
t.Fatalf("expected missing file to be skipped silently, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("stat error", func(t *testing.T) {
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) {
|
||||
return nil, fmt.Errorf("boom")
|
||||
})
|
||||
|
||||
unsafe, reason := isUnsafeFile("broken.log", tempDir)
|
||||
if !unsafe || !strings.Contains(reason, "stat failed") {
|
||||
t.Fatalf("expected stat failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("EvalSymlinks error", func(t *testing.T) {
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) {
|
||||
return fakeFileInfo{}, nil
|
||||
})
|
||||
stubEvalSymlinks(t, func(string) (string, error) {
|
||||
return "", fmt.Errorf("resolve failed")
|
||||
})
|
||||
|
||||
unsafe, reason := isUnsafeFile("cannot-resolve.log", tempDir)
|
||||
if !unsafe || !strings.Contains(reason, "path resolution failed") {
|
||||
t.Fatalf("expected resolution failure to be unsafe, got unsafe=%v reason=%q", unsafe, reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
115
codeagent-wrapper/logger_suffix_test.go
Normal file
115
codeagent-wrapper/logger_suffix_test.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoggerWithSuffixNamingAndIsolation(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
taskA := "task-1"
|
||||
taskB := "task-2"
|
||||
|
||||
loggerA, err := NewLoggerWithSuffix(taskA)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskA, err)
|
||||
}
|
||||
defer loggerA.Close()
|
||||
|
||||
loggerB, err := NewLoggerWithSuffix(taskB)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", taskB, err)
|
||||
}
|
||||
defer loggerB.Close()
|
||||
|
||||
wantA := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), taskA))
|
||||
if loggerA.Path() != wantA {
|
||||
t.Fatalf("loggerA path = %q, want %q", loggerA.Path(), wantA)
|
||||
}
|
||||
|
||||
wantB := filepath.Join(tempDir, fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), taskB))
|
||||
if loggerB.Path() != wantB {
|
||||
t.Fatalf("loggerB path = %q, want %q", loggerB.Path(), wantB)
|
||||
}
|
||||
|
||||
if loggerA.Path() == loggerB.Path() {
|
||||
t.Fatalf("expected different log files, got %q", loggerA.Path())
|
||||
}
|
||||
|
||||
loggerA.Info("from taskA")
|
||||
loggerB.Info("from taskB")
|
||||
loggerA.Flush()
|
||||
loggerB.Flush()
|
||||
|
||||
dataA, err := os.ReadFile(loggerA.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read loggerA file: %v", err)
|
||||
}
|
||||
dataB, err := os.ReadFile(loggerB.Path())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read loggerB file: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(dataA), "from taskA") {
|
||||
t.Fatalf("loggerA missing its message, got: %q", string(dataA))
|
||||
}
|
||||
if strings.Contains(string(dataA), "from taskB") {
|
||||
t.Fatalf("loggerA contains loggerB message, got: %q", string(dataA))
|
||||
}
|
||||
if !strings.Contains(string(dataB), "from taskB") {
|
||||
t.Fatalf("loggerB missing its message, got: %q", string(dataB))
|
||||
}
|
||||
if strings.Contains(string(dataB), "from taskA") {
|
||||
t.Fatalf("loggerB contains loggerA message, got: %q", string(dataB))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
noWrite := filepath.Join(base, "ro")
|
||||
if err := os.Mkdir(noWrite, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(noWrite, 0o700) })
|
||||
setTempDirEnv(t, noWrite)
|
||||
|
||||
logger, err := NewLoggerWithSuffix("task-err")
|
||||
if err == nil {
|
||||
_ = logger.Close()
|
||||
t.Fatalf("expected error when temp dir is not writable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoggerWithSuffixSanitizesUnsafeSuffix(t *testing.T) {
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
raw := "../bad id/with?chars"
|
||||
safe := sanitizeLogSuffix(raw)
|
||||
if safe == "" {
|
||||
t.Fatalf("sanitizeLogSuffix returned empty string")
|
||||
}
|
||||
if strings.ContainsAny(safe, "/\\") {
|
||||
t.Fatalf("sanitized suffix should not contain path separators, got %q", safe)
|
||||
}
|
||||
|
||||
logger, err := NewLoggerWithSuffix(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithSuffix(%q) error = %v", raw, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = logger.Close()
|
||||
_ = os.Remove(logger.Path())
|
||||
})
|
||||
|
||||
wantBase := fmt.Sprintf("%s-%d-%s.log", primaryLogPrefix(), os.Getpid(), safe)
|
||||
if gotBase := filepath.Base(logger.Path()); gotBase != wantBase {
|
||||
t.Fatalf("log filename = %q, want %q", gotBase, wantBase)
|
||||
}
|
||||
if dir := filepath.Dir(logger.Path()); dir != tempDir {
|
||||
t.Fatalf("logger path dir = %q, want %q", dir, tempDir)
|
||||
}
|
||||
}
|
||||
1128
codeagent-wrapper/logger_test.go
Normal file
1128
codeagent-wrapper/logger_test.go
Normal file
File diff suppressed because it is too large
Load Diff
627
codeagent-wrapper/main.go
Normal file
627
codeagent-wrapper/main.go
Normal file
@@ -0,0 +1,627 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
version = "5.6.4"
|
||||
defaultWorkdir = "."
|
||||
defaultTimeout = 7200 // seconds (2 hours)
|
||||
defaultCoverageTarget = 90.0
|
||||
codexLogLineLimit = 1000
|
||||
stdinSpecialChars = "\n\\\"'`$"
|
||||
stderrCaptureLimit = 4 * 1024
|
||||
defaultBackendName = "codex"
|
||||
defaultCodexCommand = "codex"
|
||||
|
||||
// stdout close reasons
|
||||
stdoutCloseReasonWait = "wait-done"
|
||||
stdoutCloseReasonDrain = "drain-timeout"
|
||||
stdoutCloseReasonCtx = "context-cancel"
|
||||
stdoutDrainTimeout = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// Test hooks for dependency injection
|
||||
var (
|
||||
stdinReader io.Reader = os.Stdin
|
||||
isTerminalFn = defaultIsTerminal
|
||||
codexCommand = defaultCodexCommand
|
||||
cleanupHook func()
|
||||
loggerPtr atomic.Pointer[Logger]
|
||||
|
||||
buildCodexArgsFn = buildCodexArgs
|
||||
selectBackendFn = selectBackend
|
||||
commandContext = exec.CommandContext
|
||||
cleanupLogsFn = cleanupOldLogs
|
||||
signalNotifyFn = signal.Notify
|
||||
signalStopFn = signal.Stop
|
||||
terminateCommandFn = terminateCommand
|
||||
defaultBuildArgsFn = buildCodexArgs
|
||||
runTaskFn = runCodexTask
|
||||
exitFn = os.Exit
|
||||
)
|
||||
|
||||
var forceKillDelay atomic.Int32
|
||||
|
||||
func init() {
|
||||
forceKillDelay.Store(5) // seconds - default value
|
||||
}
|
||||
|
||||
func runStartupCleanup() {
|
||||
if cleanupLogsFn == nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
|
||||
}
|
||||
}()
|
||||
if _, err := cleanupLogsFn(); err != nil {
|
||||
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
func runCleanupMode() int {
|
||||
if cleanupLogsFn == nil {
|
||||
fmt.Fprintln(os.Stderr, "Cleanup failed: log cleanup function not configured")
|
||||
return 1
|
||||
}
|
||||
|
||||
stats, err := cleanupLogsFn()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cleanup failed: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
fmt.Println("Cleanup completed")
|
||||
fmt.Printf("Files scanned: %d\n", stats.Scanned)
|
||||
fmt.Printf("Files deleted: %d\n", stats.Deleted)
|
||||
if len(stats.DeletedFiles) > 0 {
|
||||
for _, f := range stats.DeletedFiles {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
fmt.Printf("Files kept: %d\n", stats.Kept)
|
||||
if len(stats.KeptFiles) > 0 {
|
||||
for _, f := range stats.KeptFiles {
|
||||
fmt.Printf(" - %s\n", f)
|
||||
}
|
||||
}
|
||||
if stats.Errors > 0 {
|
||||
fmt.Printf("Deletion errors: %d\n", stats.Errors)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func main() {
|
||||
exitCode := run()
|
||||
exitFn(exitCode)
|
||||
}
|
||||
|
||||
// run is the main logic, returns exit code for testability
|
||||
func run() (exitCode int) {
|
||||
name := currentWrapperName()
|
||||
// Handle --version and --help first (no logger needed)
|
||||
if len(os.Args) > 1 {
|
||||
switch os.Args[1] {
|
||||
case "--version", "-v":
|
||||
fmt.Printf("%s version %s\n", name, version)
|
||||
return 0
|
||||
case "--help", "-h":
|
||||
printHelp()
|
||||
return 0
|
||||
case "--cleanup":
|
||||
return runCleanupMode()
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logger for all other commands
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
setLogger(logger)
|
||||
|
||||
defer func() {
|
||||
logger := activeLogger()
|
||||
if logger != nil {
|
||||
logger.Flush()
|
||||
}
|
||||
if err := closeLogger(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to close logger: %v\n", err)
|
||||
}
|
||||
// On failure, extract and display recent errors before removing log
|
||||
if logger != nil {
|
||||
if exitCode != 0 {
|
||||
if errors := logger.ExtractRecentErrors(10); len(errors) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "\n=== Recent Errors ===")
|
||||
for _, entry := range errors {
|
||||
fmt.Fprintln(os.Stderr, entry)
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "Log file: %s (deleted)\n", logger.Path())
|
||||
}
|
||||
}
|
||||
if err := logger.RemoveLogFile(); err != nil && !os.IsNotExist(err) {
|
||||
// Silently ignore removal errors
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer runCleanupHook()
|
||||
|
||||
// Clean up stale logs from previous runs.
|
||||
runStartupCleanup()
|
||||
|
||||
// Handle remaining commands
|
||||
if len(os.Args) > 1 {
|
||||
args := os.Args[1:]
|
||||
parallelIndex := -1
|
||||
for i, arg := range args {
|
||||
if arg == "--parallel" {
|
||||
parallelIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if parallelIndex != -1 {
|
||||
backendName := defaultBackendName
|
||||
model := ""
|
||||
fullOutput := false
|
||||
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
|
||||
var extras []string
|
||||
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--parallel":
|
||||
continue
|
||||
case arg == "--full-output":
|
||||
fullOutput = true
|
||||
case arg == "--backend":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
|
||||
return 1
|
||||
}
|
||||
backendName = args[i+1]
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--backend="):
|
||||
value := strings.TrimPrefix(arg, "--backend=")
|
||||
if value == "" {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
|
||||
return 1
|
||||
}
|
||||
backendName = value
|
||||
case arg == "--model":
|
||||
if i+1 >= len(args) {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --model flag requires a value")
|
||||
return 1
|
||||
}
|
||||
model = args[i+1]
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--model="):
|
||||
value := strings.TrimPrefix(arg, "--model=")
|
||||
if value == "" {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --model flag requires a value")
|
||||
return 1
|
||||
}
|
||||
model = value
|
||||
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
|
||||
skipPermissions = true
|
||||
case strings.HasPrefix(arg, "--skip-permissions="):
|
||||
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
|
||||
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
|
||||
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
|
||||
default:
|
||||
extras = append(extras, arg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(extras) > 0 {
|
||||
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --full-output and --skip-permissions are allowed.")
|
||||
fmt.Fprintln(os.Stderr, "Usage examples:")
|
||||
fmt.Fprintf(os.Stderr, " %s --parallel < tasks.txt\n", name)
|
||||
fmt.Fprintf(os.Stderr, " echo '...' | %s --parallel\n", name)
|
||||
fmt.Fprintf(os.Stderr, " %s --parallel <<'EOF'\n", name)
|
||||
fmt.Fprintf(os.Stderr, " %s --parallel --full-output <<'EOF' # include full task output\n", name)
|
||||
return 1
|
||||
}
|
||||
|
||||
backend, err := selectBackendFn(backendName)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
backendName = backend.Name()
|
||||
|
||||
data, err := io.ReadAll(stdinReader)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to read stdin: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
cfg, err := parseParallelConfig(data)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
cfg.GlobalBackend = backendName
|
||||
model = strings.TrimSpace(model)
|
||||
for i := range cfg.Tasks {
|
||||
if strings.TrimSpace(cfg.Tasks[i].Backend) == "" {
|
||||
cfg.Tasks[i].Backend = backendName
|
||||
}
|
||||
if strings.TrimSpace(cfg.Tasks[i].Model) == "" && model != "" {
|
||||
cfg.Tasks[i].Model = model
|
||||
}
|
||||
cfg.Tasks[i].SkipPermissions = cfg.Tasks[i].SkipPermissions || skipPermissions
|
||||
}
|
||||
|
||||
timeoutSec := resolveTimeout()
|
||||
layers, err := topologicalSort(cfg.Tasks)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
results := executeConcurrent(layers, timeoutSec)
|
||||
|
||||
// Extract structured report fields from each result
|
||||
for i := range results {
|
||||
results[i].CoverageTarget = defaultCoverageTarget
|
||||
if results[i].Message == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lines := strings.Split(results[i].Message, "\n")
|
||||
|
||||
// Coverage extraction
|
||||
results[i].Coverage = extractCoverageFromLines(lines)
|
||||
results[i].CoverageNum = extractCoverageNum(results[i].Coverage)
|
||||
|
||||
// Files changed
|
||||
results[i].FilesChanged = extractFilesChangedFromLines(lines)
|
||||
|
||||
// Test results
|
||||
results[i].TestsPassed, results[i].TestsFailed = extractTestResultsFromLines(lines)
|
||||
|
||||
// Key output summary
|
||||
results[i].KeyOutput = extractKeyOutputFromLines(lines, 150)
|
||||
}
|
||||
|
||||
// Default: summary mode (context-efficient)
|
||||
// --full-output: legacy full output mode
|
||||
fmt.Println(generateFinalOutputWithMode(results, !fullOutput))
|
||||
|
||||
exitCode = 0
|
||||
for _, res := range results {
|
||||
if res.ExitCode != 0 {
|
||||
exitCode = res.ExitCode
|
||||
}
|
||||
}
|
||||
|
||||
return exitCode
|
||||
}
|
||||
}
|
||||
|
||||
logInfo("Script started")
|
||||
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
logError(err.Error())
|
||||
return 1
|
||||
}
|
||||
logInfo(fmt.Sprintf("Parsed args: mode=%s, task_len=%d, backend=%s", cfg.Mode, len(cfg.Task), cfg.Backend))
|
||||
|
||||
backend, err := selectBackendFn(cfg.Backend)
|
||||
if err != nil {
|
||||
logError(err.Error())
|
||||
return 1
|
||||
}
|
||||
cfg.Backend = backend.Name()
|
||||
|
||||
cmdInjected := codexCommand != defaultCodexCommand
|
||||
argsInjected := buildCodexArgsFn != nil && reflect.ValueOf(buildCodexArgsFn).Pointer() != reflect.ValueOf(defaultBuildArgsFn).Pointer()
|
||||
|
||||
// Wire selected backend into runtime hooks for the rest of the execution,
|
||||
// but preserve any injected test hooks for the default backend.
|
||||
if backend.Name() != defaultBackendName || !cmdInjected {
|
||||
codexCommand = backend.Command()
|
||||
}
|
||||
if backend.Name() != defaultBackendName || !argsInjected {
|
||||
buildCodexArgsFn = backend.BuildArgs
|
||||
}
|
||||
logInfo(fmt.Sprintf("Selected backend: %s", backend.Name()))
|
||||
|
||||
timeoutSec := resolveTimeout()
|
||||
logInfo(fmt.Sprintf("Timeout: %ds", timeoutSec))
|
||||
cfg.Timeout = timeoutSec
|
||||
|
||||
var taskText string
|
||||
var piped bool
|
||||
|
||||
if cfg.ExplicitStdin {
|
||||
logInfo("Explicit stdin mode: reading task from stdin")
|
||||
data, err := io.ReadAll(stdinReader)
|
||||
if err != nil {
|
||||
logError("Failed to read stdin: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
taskText = string(data)
|
||||
if taskText == "" {
|
||||
logError("Explicit stdin mode requires task input from stdin")
|
||||
return 1
|
||||
}
|
||||
piped = !isTerminal()
|
||||
} else {
|
||||
pipedTask, err := readPipedTask()
|
||||
if err != nil {
|
||||
logError("Failed to read piped stdin: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
piped = pipedTask != ""
|
||||
if piped {
|
||||
taskText = pipedTask
|
||||
} else {
|
||||
taskText = cfg.Task
|
||||
}
|
||||
}
|
||||
|
||||
if strings.TrimSpace(cfg.PromptFile) != "" {
|
||||
prompt, err := readAgentPromptFile(cfg.PromptFile, cfg.PromptFileExplicit)
|
||||
if err != nil {
|
||||
logError("Failed to read prompt file: " + err.Error())
|
||||
return 1
|
||||
}
|
||||
taskText = wrapTaskWithAgentPrompt(prompt, taskText)
|
||||
}
|
||||
|
||||
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
|
||||
|
||||
targetArg := taskText
|
||||
if useStdin {
|
||||
targetArg = "-"
|
||||
}
|
||||
codexArgs := buildCodexArgsFn(cfg, targetArg)
|
||||
|
||||
// Print startup information to stderr
|
||||
fmt.Fprintf(os.Stderr, "[%s]\n", name)
|
||||
fmt.Fprintf(os.Stderr, " Backend: %s\n", cfg.Backend)
|
||||
fmt.Fprintf(os.Stderr, " Command: %s %s\n", codexCommand, strings.Join(codexArgs, " "))
|
||||
fmt.Fprintf(os.Stderr, " PID: %d\n", os.Getpid())
|
||||
fmt.Fprintf(os.Stderr, " Log: %s\n", logger.Path())
|
||||
|
||||
if useStdin {
|
||||
var reasons []string
|
||||
if piped {
|
||||
reasons = append(reasons, "piped input")
|
||||
}
|
||||
if cfg.ExplicitStdin {
|
||||
reasons = append(reasons, "explicit \"-\"")
|
||||
}
|
||||
if strings.Contains(taskText, "\n") {
|
||||
reasons = append(reasons, "newline")
|
||||
}
|
||||
if strings.Contains(taskText, "\\") {
|
||||
reasons = append(reasons, "backslash")
|
||||
}
|
||||
if strings.Contains(taskText, "\"") {
|
||||
reasons = append(reasons, "double-quote")
|
||||
}
|
||||
if strings.Contains(taskText, "'") {
|
||||
reasons = append(reasons, "single-quote")
|
||||
}
|
||||
if strings.Contains(taskText, "`") {
|
||||
reasons = append(reasons, "backtick")
|
||||
}
|
||||
if strings.Contains(taskText, "$") {
|
||||
reasons = append(reasons, "dollar")
|
||||
}
|
||||
if len(taskText) > 800 {
|
||||
reasons = append(reasons, "length>800")
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
logWarn(fmt.Sprintf("Using stdin mode for task due to: %s", strings.Join(reasons, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
logInfo(fmt.Sprintf("%s running...", cfg.Backend))
|
||||
|
||||
taskSpec := TaskSpec{
|
||||
Task: taskText,
|
||||
WorkDir: cfg.WorkDir,
|
||||
Mode: cfg.Mode,
|
||||
SessionID: cfg.SessionID,
|
||||
Model: cfg.Model,
|
||||
ReasoningEffort: cfg.ReasoningEffort,
|
||||
SkipPermissions: cfg.SkipPermissions,
|
||||
UseStdin: useStdin,
|
||||
}
|
||||
|
||||
result := runTaskFn(taskSpec, false, cfg.Timeout)
|
||||
|
||||
if result.ExitCode != 0 {
|
||||
return result.ExitCode
|
||||
}
|
||||
|
||||
fmt.Println(result.Message)
|
||||
if result.SessionID != "" {
|
||||
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
func readAgentPromptFile(path string, allowOutsideClaudeDir bool) (string, error) {
|
||||
raw := strings.TrimSpace(path)
|
||||
if raw == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
expanded := raw
|
||||
if raw == "~" || strings.HasPrefix(raw, "~/") || strings.HasPrefix(raw, "~\\") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if raw == "~" {
|
||||
expanded = home
|
||||
} else {
|
||||
expanded = home + raw[1:]
|
||||
}
|
||||
}
|
||||
|
||||
absPath, err := filepath.Abs(expanded)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
absPath = filepath.Clean(absPath)
|
||||
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
if !allowOutsideClaudeDir {
|
||||
return "", err
|
||||
}
|
||||
logWarn(fmt.Sprintf("Failed to resolve home directory for prompt file validation: %v; proceeding without restriction", err))
|
||||
} else {
|
||||
allowedDir := filepath.Clean(filepath.Join(home, ".claude"))
|
||||
allowedAbs, err := filepath.Abs(allowedDir)
|
||||
if err == nil {
|
||||
allowedDir = filepath.Clean(allowedAbs)
|
||||
}
|
||||
|
||||
isWithinDir := func(path, dir string) bool {
|
||||
rel, err := filepath.Rel(dir, path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel = filepath.Clean(rel)
|
||||
if rel == "." {
|
||||
return true
|
||||
}
|
||||
if rel == ".." {
|
||||
return false
|
||||
}
|
||||
prefix := ".." + string(os.PathSeparator)
|
||||
return !strings.HasPrefix(rel, prefix)
|
||||
}
|
||||
|
||||
if !allowOutsideClaudeDir {
|
||||
if !isWithinDir(absPath, allowedDir) {
|
||||
logWarn(fmt.Sprintf("Refusing to read prompt file outside %s: %s", allowedDir, absPath))
|
||||
return "", fmt.Errorf("prompt file must be under %s", allowedDir)
|
||||
}
|
||||
resolvedPath, errPath := filepath.EvalSymlinks(absPath)
|
||||
resolvedBase, errBase := filepath.EvalSymlinks(allowedDir)
|
||||
if errPath == nil && errBase == nil {
|
||||
resolvedPath = filepath.Clean(resolvedPath)
|
||||
resolvedBase = filepath.Clean(resolvedBase)
|
||||
if !isWithinDir(resolvedPath, resolvedBase) {
|
||||
logWarn(fmt.Sprintf("Refusing to read prompt file outside %s (resolved): %s", resolvedBase, resolvedPath))
|
||||
return "", fmt.Errorf("prompt file must be under %s", resolvedBase)
|
||||
}
|
||||
}
|
||||
} else if !isWithinDir(absPath, allowedDir) {
|
||||
logWarn(fmt.Sprintf("Reading prompt file outside %s: %s", allowedDir, absPath))
|
||||
}
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimRight(string(data), "\r\n"), nil
|
||||
}
|
||||
|
||||
func wrapTaskWithAgentPrompt(prompt string, task string) string {
|
||||
return "<agent-prompt>\n" + prompt + "\n</agent-prompt>\n\n" + task
|
||||
}
|
||||
|
||||
func setLogger(l *Logger) {
|
||||
loggerPtr.Store(l)
|
||||
}
|
||||
|
||||
func closeLogger() error {
|
||||
logger := loggerPtr.Swap(nil)
|
||||
if logger == nil {
|
||||
return nil
|
||||
}
|
||||
return logger.Close()
|
||||
}
|
||||
|
||||
func activeLogger() *Logger {
|
||||
return loggerPtr.Load()
|
||||
}
|
||||
|
||||
func logInfo(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Info(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func logWarn(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Warn(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func logError(msg string) {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func runCleanupHook() {
|
||||
if logger := activeLogger(); logger != nil {
|
||||
logger.Flush()
|
||||
}
|
||||
if cleanupHook != nil {
|
||||
cleanupHook()
|
||||
}
|
||||
}
|
||||
|
||||
func printHelp() {
|
||||
name := currentWrapperName()
|
||||
help := fmt.Sprintf(`%[1]s - Go wrapper for AI CLI backends
|
||||
|
||||
Usage:
|
||||
%[1]s "task" [workdir]
|
||||
%[1]s --backend claude "task" [workdir]
|
||||
%[1]s --prompt-file /path/to/prompt.md "task" [workdir]
|
||||
%[1]s - [workdir] Read task from stdin
|
||||
%[1]s resume <session_id> "task" [workdir]
|
||||
%[1]s resume <session_id> - [workdir]
|
||||
%[1]s --parallel Run tasks in parallel (config from stdin)
|
||||
%[1]s --parallel --full-output Run tasks in parallel with full output (legacy)
|
||||
%[1]s --version
|
||||
%[1]s --help
|
||||
|
||||
Parallel mode examples:
|
||||
%[1]s --parallel < tasks.txt
|
||||
echo '...' | %[1]s --parallel
|
||||
%[1]s --parallel --full-output < tasks.txt
|
||||
%[1]s --parallel <<'EOF'
|
||||
|
||||
Environment Variables:
|
||||
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
|
||||
CODEAGENT_ASCII_MODE Use ASCII symbols instead of Unicode (PASS/WARN/FAIL)
|
||||
|
||||
Exit Codes:
|
||||
0 Success
|
||||
1 General error (missing args, no output)
|
||||
124 Timeout
|
||||
127 backend command not found
|
||||
130 Interrupted (Ctrl+C)
|
||||
* Passthrough from backend process`, name)
|
||||
fmt.Println(help)
|
||||
}
|
||||
896
codeagent-wrapper/main_integration_test.go
Normal file
896
codeagent-wrapper/main_integration_test.go
Normal file
@@ -0,0 +1,896 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type integrationSummary struct {
|
||||
Total int `json:"total"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
}
|
||||
|
||||
type integrationOutput struct {
|
||||
Results []TaskResult `json:"results"`
|
||||
Summary integrationSummary `json:"summary"`
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func()) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stdout = w
|
||||
|
||||
fn()
|
||||
|
||||
w.Close()
|
||||
os.Stdout = old
|
||||
|
||||
var buf bytes.Buffer
|
||||
io.Copy(&buf, r)
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
|
||||
t.Helper()
|
||||
var payload integrationOutput
|
||||
|
||||
lines := strings.Split(out, "\n")
|
||||
var currentTask *TaskResult
|
||||
inTaskResults := false
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Parse new format header: "X tasks | Y passed | Z failed"
|
||||
if strings.Contains(line, "tasks |") && strings.Contains(line, "passed |") {
|
||||
parts := strings.Split(line, "|")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if strings.HasSuffix(p, "tasks") {
|
||||
fmt.Sscanf(p, "%d tasks", &payload.Summary.Total)
|
||||
} else if strings.HasSuffix(p, "passed") {
|
||||
fmt.Sscanf(p, "%d passed", &payload.Summary.Success)
|
||||
} else if strings.HasSuffix(p, "failed") {
|
||||
fmt.Sscanf(p, "%d failed", &payload.Summary.Failed)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Total:") {
|
||||
// Legacy format: "Total: X | Success: Y | Failed: Z"
|
||||
parts := strings.Split(line, "|")
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if strings.HasPrefix(p, "Total:") {
|
||||
fmt.Sscanf(p, "Total: %d", &payload.Summary.Total)
|
||||
} else if strings.HasPrefix(p, "Success:") {
|
||||
fmt.Sscanf(p, "Success: %d", &payload.Summary.Success)
|
||||
} else if strings.HasPrefix(p, "Failed:") {
|
||||
fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed)
|
||||
}
|
||||
}
|
||||
} else if line == "## Task Results" {
|
||||
inTaskResults = true
|
||||
} else if line == "## Summary" {
|
||||
// End of task results section
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
currentTask = nil
|
||||
}
|
||||
inTaskResults = false
|
||||
} else if inTaskResults && strings.HasPrefix(line, "### ") {
|
||||
// New task: ### task-id ✓ 92% or ### task-id PASS 92% (ASCII mode)
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
}
|
||||
currentTask = &TaskResult{}
|
||||
|
||||
taskLine := strings.TrimPrefix(line, "### ")
|
||||
success, warning, failed := getStatusSymbols()
|
||||
// Parse different formats
|
||||
if strings.Contains(taskLine, " "+success) {
|
||||
parts := strings.Split(taskLine, " "+success)
|
||||
currentTask.TaskID = strings.TrimSpace(parts[0])
|
||||
currentTask.ExitCode = 0
|
||||
// Extract coverage if present
|
||||
if len(parts) > 1 {
|
||||
coveragePart := strings.TrimSpace(parts[1])
|
||||
if strings.HasSuffix(coveragePart, "%") {
|
||||
currentTask.Coverage = coveragePart
|
||||
}
|
||||
}
|
||||
} else if strings.Contains(taskLine, " "+warning) {
|
||||
parts := strings.Split(taskLine, " "+warning)
|
||||
currentTask.TaskID = strings.TrimSpace(parts[0])
|
||||
currentTask.ExitCode = 0
|
||||
} else if strings.Contains(taskLine, " "+failed) {
|
||||
parts := strings.Split(taskLine, " "+failed)
|
||||
currentTask.TaskID = strings.TrimSpace(parts[0])
|
||||
currentTask.ExitCode = 1
|
||||
} else {
|
||||
currentTask.TaskID = taskLine
|
||||
}
|
||||
} else if currentTask != nil && inTaskResults {
|
||||
// Parse task details
|
||||
if strings.HasPrefix(line, "Exit code:") {
|
||||
fmt.Sscanf(line, "Exit code: %d", ¤tTask.ExitCode)
|
||||
} else if strings.HasPrefix(line, "Error:") {
|
||||
currentTask.Error = strings.TrimPrefix(line, "Error: ")
|
||||
} else if strings.HasPrefix(line, "Log:") {
|
||||
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
|
||||
} else if strings.HasPrefix(line, "Did:") {
|
||||
currentTask.KeyOutput = strings.TrimSpace(strings.TrimPrefix(line, "Did:"))
|
||||
} else if strings.HasPrefix(line, "Detail:") {
|
||||
// Error detail for failed tasks
|
||||
if currentTask.Message == "" {
|
||||
currentTask.Message = strings.TrimSpace(strings.TrimPrefix(line, "Detail:"))
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(line, "--- Task:") {
|
||||
// Legacy full output format
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
}
|
||||
currentTask = &TaskResult{}
|
||||
currentTask.TaskID = strings.TrimSuffix(strings.TrimPrefix(line, "--- Task: "), " ---")
|
||||
} else if currentTask != nil && !inTaskResults {
|
||||
// Legacy format parsing
|
||||
if strings.HasPrefix(line, "Status: SUCCESS") {
|
||||
currentTask.ExitCode = 0
|
||||
} else if strings.HasPrefix(line, "Status: FAILED") {
|
||||
if strings.Contains(line, "exit code") {
|
||||
fmt.Sscanf(line, "Status: FAILED (exit code %d)", ¤tTask.ExitCode)
|
||||
} else {
|
||||
currentTask.ExitCode = 1
|
||||
}
|
||||
} else if strings.HasPrefix(line, "Error:") {
|
||||
currentTask.Error = strings.TrimPrefix(line, "Error: ")
|
||||
} else if strings.HasPrefix(line, "Session:") {
|
||||
currentTask.SessionID = strings.TrimPrefix(line, "Session: ")
|
||||
} else if strings.HasPrefix(line, "Log:") {
|
||||
currentTask.LogPath = strings.TrimSpace(strings.TrimPrefix(line, "Log:"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last task
|
||||
if currentTask != nil {
|
||||
payload.Results = append(payload.Results, *currentTask)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
func findResultByID(t *testing.T, payload integrationOutput, id string) TaskResult {
|
||||
t.Helper()
|
||||
for _, res := range payload.Results {
|
||||
if res.TaskID == id {
|
||||
return res
|
||||
}
|
||||
}
|
||||
t.Fatalf("result for task %s not found", id)
|
||||
return TaskResult{}
|
||||
}
|
||||
|
||||
func TestRunParallelEndToEnd_OrderAndConcurrency(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
})
|
||||
|
||||
input := `---TASK---
|
||||
id: A
|
||||
---CONTENT---
|
||||
task-a
|
||||
---TASK---
|
||||
id: B
|
||||
dependencies: A
|
||||
---CONTENT---
|
||||
task-b
|
||||
---TASK---
|
||||
id: C
|
||||
dependencies: B
|
||||
---CONTENT---
|
||||
task-c
|
||||
---TASK---
|
||||
id: D
|
||||
---CONTENT---
|
||||
task-d
|
||||
---TASK---
|
||||
id: E
|
||||
---CONTENT---
|
||||
task-e`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
||||
|
||||
var mu sync.Mutex
|
||||
starts := make(map[string]time.Time)
|
||||
ends := make(map[string]time.Time)
|
||||
var running int64
|
||||
var maxParallel int64
|
||||
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
start := time.Now()
|
||||
mu.Lock()
|
||||
starts[task.ID] = start
|
||||
mu.Unlock()
|
||||
|
||||
cur := atomic.AddInt64(&running, 1)
|
||||
for {
|
||||
prev := atomic.LoadInt64(&maxParallel)
|
||||
if cur <= prev {
|
||||
break
|
||||
}
|
||||
if atomic.CompareAndSwapInt64(&maxParallel, prev, cur) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
|
||||
mu.Lock()
|
||||
ends[task.ID] = time.Now()
|
||||
mu.Unlock()
|
||||
|
||||
atomic.AddInt64(&running, -1)
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: task.Task}
|
||||
}
|
||||
|
||||
var exitCode int
|
||||
output := captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("run() exit = %d, want 0", exitCode)
|
||||
}
|
||||
|
||||
payload := parseIntegrationOutput(t, output)
|
||||
if payload.Summary.Failed != 0 || payload.Summary.Total != 5 || payload.Summary.Success != 5 {
|
||||
t.Fatalf("unexpected summary: %+v", payload.Summary)
|
||||
}
|
||||
|
||||
aEnd := ends["A"]
|
||||
bStart := starts["B"]
|
||||
cStart := starts["C"]
|
||||
bEnd := ends["B"]
|
||||
if aEnd.IsZero() || bStart.IsZero() || bEnd.IsZero() || cStart.IsZero() {
|
||||
t.Fatalf("missing timestamps, starts=%v ends=%v", starts, ends)
|
||||
}
|
||||
if !aEnd.Before(bStart) && !aEnd.Equal(bStart) {
|
||||
t.Fatalf("B should start after A ends: A_end=%v B_start=%v", aEnd, bStart)
|
||||
}
|
||||
if !bEnd.Before(cStart) && !bEnd.Equal(cStart) {
|
||||
t.Fatalf("C should start after B ends: B_end=%v C_start=%v", bEnd, cStart)
|
||||
}
|
||||
|
||||
dStart := starts["D"]
|
||||
eStart := starts["E"]
|
||||
if dStart.IsZero() || eStart.IsZero() {
|
||||
t.Fatalf("missing D/E start times: %v", starts)
|
||||
}
|
||||
delta := dStart.Sub(eStart)
|
||||
if delta < 0 {
|
||||
delta = -delta
|
||||
}
|
||||
if delta > 25*time.Millisecond {
|
||||
t.Fatalf("D and E should run in parallel, delta=%v", delta)
|
||||
}
|
||||
if maxParallel < 2 {
|
||||
t.Fatalf("expected at least 2 concurrent tasks, got %d", maxParallel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelCycleDetectionStopsExecution(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
t.Fatalf("task %s should not execute on cycle", task.ID)
|
||||
return TaskResult{}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
})
|
||||
|
||||
input := `---TASK---
|
||||
id: A
|
||||
dependencies: B
|
||||
---CONTENT---
|
||||
a
|
||||
---TASK---
|
||||
id: B
|
||||
dependencies: A
|
||||
---CONTENT---
|
||||
b`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
||||
|
||||
exitCode := 0
|
||||
output := captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
if exitCode == 0 {
|
||||
t.Fatalf("cycle should cause non-zero exit, got %d", exitCode)
|
||||
}
|
||||
if strings.TrimSpace(output) != "" {
|
||||
t.Fatalf("expected no JSON output on cycle, got %q", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelOutputsIncludeLogPaths(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
logPathFor := func(id string) string {
|
||||
return filepath.Join(tempDir, fmt.Sprintf("%s.log", id))
|
||||
}
|
||||
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
res := TaskResult{
|
||||
TaskID: task.ID,
|
||||
Message: fmt.Sprintf("result-%s", task.ID),
|
||||
SessionID: fmt.Sprintf("session-%s", task.ID),
|
||||
LogPath: logPathFor(task.ID),
|
||||
}
|
||||
if task.ID == "beta" {
|
||||
res.ExitCode = 9
|
||||
res.Error = "boom"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
input := `---TASK---
|
||||
id: alpha
|
||||
---CONTENT---
|
||||
task-alpha
|
||||
---TASK---
|
||||
id: beta
|
||||
---CONTENT---
|
||||
task-beta`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codex-wrapper", "--parallel"}
|
||||
|
||||
var exitCode int
|
||||
output := captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
if exitCode != 9 {
|
||||
t.Fatalf("parallel run exit=%d, want 9", exitCode)
|
||||
}
|
||||
|
||||
payload := parseIntegrationOutput(t, output)
|
||||
alpha := findResultByID(t, payload, "alpha")
|
||||
beta := findResultByID(t, payload, "beta")
|
||||
|
||||
if alpha.LogPath != logPathFor("alpha") {
|
||||
t.Fatalf("alpha log path = %q, want %q", alpha.LogPath, logPathFor("alpha"))
|
||||
}
|
||||
if beta.LogPath != logPathFor("beta") {
|
||||
t.Fatalf("beta log path = %q, want %q", beta.LogPath, logPathFor("beta"))
|
||||
}
|
||||
|
||||
for _, id := range []string{"alpha", "beta"} {
|
||||
// Summary mode shows log paths in table format, not "Log: xxx"
|
||||
logPath := logPathFor(id)
|
||||
if !strings.Contains(output, logPath) {
|
||||
t.Fatalf("parallel output missing log path %q for %s:\n%s", logPath, id, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelStartupLogsPrinted(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
input := `---TASK---
|
||||
id: a
|
||||
---CONTENT---
|
||||
fail
|
||||
---TASK---
|
||||
id: b
|
||||
---CONTENT---
|
||||
ok-b
|
||||
---TASK---
|
||||
id: c
|
||||
dependencies: a
|
||||
---CONTENT---
|
||||
should-skip
|
||||
---TASK---
|
||||
id: d
|
||||
---CONTENT---
|
||||
ok-d`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codex-wrapper", "--parallel"}
|
||||
|
||||
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
|
||||
origRun := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
path := expectedLog
|
||||
if logger := activeLogger(); logger != nil && logger.Path() != "" {
|
||||
path = logger.Path()
|
||||
}
|
||||
if task.ID == "a" {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 3, Error: "boom", LogPath: path}
|
||||
}
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: task.Task, LogPath: path}
|
||||
}
|
||||
t.Cleanup(func() { runCodexTaskFn = origRun })
|
||||
|
||||
var exitCode int
|
||||
var stdoutOut string
|
||||
stderrOut := captureStderr(t, func() {
|
||||
stdoutOut = captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
})
|
||||
|
||||
if exitCode == 0 {
|
||||
t.Fatalf("expected non-zero exit due to task failure, got %d", exitCode)
|
||||
}
|
||||
if stdoutOut == "" {
|
||||
t.Fatalf("expected parallel summary on stdout")
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stderrOut), "\n")
|
||||
var bannerSeen bool
|
||||
var taskLines []string
|
||||
for _, raw := range lines {
|
||||
line := strings.TrimSpace(raw)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if line == "=== Starting Parallel Execution ===" {
|
||||
if bannerSeen {
|
||||
t.Fatalf("banner printed multiple times:\n%s", stderrOut)
|
||||
}
|
||||
bannerSeen = true
|
||||
continue
|
||||
}
|
||||
taskLines = append(taskLines, line)
|
||||
}
|
||||
|
||||
if !bannerSeen {
|
||||
t.Fatalf("expected startup banner in stderr, got:\n%s", stderrOut)
|
||||
}
|
||||
|
||||
// After parallel log isolation fix, each task has its own log file
|
||||
expectedLines := map[string]struct{}{
|
||||
fmt.Sprintf("Task a: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d-a.log", os.Getpid()))): {},
|
||||
fmt.Sprintf("Task b: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d-b.log", os.Getpid()))): {},
|
||||
fmt.Sprintf("Task d: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d-d.log", os.Getpid()))): {},
|
||||
}
|
||||
|
||||
if len(taskLines) != len(expectedLines) {
|
||||
t.Fatalf("startup log lines mismatch, got %d lines:\n%s", len(taskLines), stderrOut)
|
||||
}
|
||||
|
||||
for _, line := range taskLines {
|
||||
if _, ok := expectedLines[line]; !ok {
|
||||
t.Fatalf("unexpected startup line %q\nstderr:\n%s", line, stderrOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
os.Args = []string{"codex-wrapper", "integration-log-check"}
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
codexCommand = "echo"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
||||
return []string{`{"type":"thread.started","thread_id":"integration-session"}` + "\n" + `{"type":"item.completed","item":{"type":"agent_message","text":"done"}}`}
|
||||
}
|
||||
|
||||
var exitCode int
|
||||
stderr := captureStderr(t, func() {
|
||||
_ = captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("run() exit=%d, want 0", exitCode)
|
||||
}
|
||||
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
wantLine := fmt.Sprintf("Log: %s", expectedLog)
|
||||
if !strings.Contains(stderr, wantLine) {
|
||||
t.Fatalf("stderr missing %q, got: %q", wantLine, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelPartialFailureBlocksDependents(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
logPathFor := func(id string) string {
|
||||
return filepath.Join(tempDir, fmt.Sprintf("%s.log", id))
|
||||
}
|
||||
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
path := logPathFor(task.ID)
|
||||
if task.ID == "A" {
|
||||
return TaskResult{TaskID: "A", ExitCode: 2, Error: "boom", LogPath: path}
|
||||
}
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: task.Task, LogPath: path}
|
||||
}
|
||||
|
||||
input := `---TASK---
|
||||
id: A
|
||||
---CONTENT---
|
||||
fail
|
||||
---TASK---
|
||||
id: B
|
||||
dependencies: A
|
||||
---CONTENT---
|
||||
blocked
|
||||
---TASK---
|
||||
id: D
|
||||
---CONTENT---
|
||||
ok-d
|
||||
---TASK---
|
||||
id: E
|
||||
---CONTENT---
|
||||
ok-e`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
||||
|
||||
var exitCode int
|
||||
output := captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
payload := parseIntegrationOutput(t, output)
|
||||
if exitCode == 0 {
|
||||
t.Fatalf("expected non-zero exit when a task fails, got %d", exitCode)
|
||||
}
|
||||
|
||||
resA := findResultByID(t, payload, "A")
|
||||
resB := findResultByID(t, payload, "B")
|
||||
resD := findResultByID(t, payload, "D")
|
||||
resE := findResultByID(t, payload, "E")
|
||||
|
||||
if resA.ExitCode == 0 {
|
||||
t.Fatalf("task A should fail, got %+v", resA)
|
||||
}
|
||||
if resB.ExitCode == 0 || !strings.Contains(resB.Error, "dependencies") {
|
||||
t.Fatalf("task B should be skipped due to dependency failure, got %+v", resB)
|
||||
}
|
||||
if resD.ExitCode != 0 || resE.ExitCode != 0 {
|
||||
t.Fatalf("independent tasks should run successfully, D=%+v E=%+v", resD, resE)
|
||||
}
|
||||
if payload.Summary.Failed != 2 || payload.Summary.Total != 4 {
|
||||
t.Fatalf("unexpected summary after partial failure: %+v", payload.Summary)
|
||||
}
|
||||
if resA.LogPath != logPathFor("A") {
|
||||
t.Fatalf("task A log path = %q, want %q", resA.LogPath, logPathFor("A"))
|
||||
}
|
||||
if resB.LogPath != "" {
|
||||
t.Fatalf("task B should not report a log path when skipped, got %q", resB.LogPath)
|
||||
}
|
||||
if resD.LogPath != logPathFor("D") || resE.LogPath != logPathFor("E") {
|
||||
t.Fatalf("expected log paths for D/E, got D=%q E=%q", resD.LogPath, resE.LogPath)
|
||||
}
|
||||
// Summary mode shows log paths in table, verify they appear in output
|
||||
for _, id := range []string{"A", "D", "E"} {
|
||||
logPath := logPathFor(id)
|
||||
if !strings.Contains(output, logPath) {
|
||||
t.Fatalf("task %s log path %q not found in output:\n%s", id, logPath, output)
|
||||
}
|
||||
}
|
||||
// Task B was skipped, should have "-" or empty log path in table
|
||||
if resB.LogPath != "" {
|
||||
t.Fatalf("skipped task B should have empty log path, got %q", resB.LogPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunParallelTimeoutPropagation(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
})
|
||||
|
||||
var receivedTimeout int
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
receivedTimeout = timeout
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 124, Error: "timeout"}
|
||||
}
|
||||
|
||||
t.Setenv("CODEX_TIMEOUT", "1")
|
||||
input := `---TASK---
|
||||
id: T
|
||||
---CONTENT---
|
||||
slow`
|
||||
stdinReader = bytes.NewReader([]byte(input))
|
||||
os.Args = []string{"codeagent-wrapper", "--parallel"}
|
||||
|
||||
exitCode := 0
|
||||
output := captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
payload := parseIntegrationOutput(t, output)
|
||||
if receivedTimeout != 1 {
|
||||
t.Fatalf("expected timeout 1s to propagate, got %d", receivedTimeout)
|
||||
}
|
||||
if exitCode != 124 {
|
||||
t.Fatalf("expected timeout exit code 124, got %d", exitCode)
|
||||
}
|
||||
if payload.Summary.Failed != 1 || payload.Summary.Total != 1 {
|
||||
t.Fatalf("unexpected summary for timeout case: %+v", payload.Summary)
|
||||
}
|
||||
res := findResultByID(t, payload, "T")
|
||||
if res.Error == "" || res.ExitCode != 124 {
|
||||
t.Fatalf("timeout result not propagated, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunConcurrentSpeedupBenchmark(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
origRun := runCodexTaskFn
|
||||
t.Cleanup(func() {
|
||||
runCodexTaskFn = origRun
|
||||
resetTestHooks()
|
||||
})
|
||||
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return TaskResult{TaskID: task.ID}
|
||||
}
|
||||
|
||||
tasks := make([]TaskSpec, 10)
|
||||
for i := range tasks {
|
||||
tasks[i] = TaskSpec{ID: fmt.Sprintf("task-%d", i)}
|
||||
}
|
||||
layers := [][]TaskSpec{tasks}
|
||||
|
||||
serialStart := time.Now()
|
||||
for _, task := range tasks {
|
||||
_ = runCodexTaskFn(task, 5)
|
||||
}
|
||||
serialElapsed := time.Since(serialStart)
|
||||
|
||||
concurrentStart := time.Now()
|
||||
_ = executeConcurrent(layers, 5)
|
||||
concurrentElapsed := time.Since(concurrentStart)
|
||||
|
||||
if concurrentElapsed >= serialElapsed/5 {
|
||||
t.Fatalf("expected concurrent time <20%% of serial, serial=%v concurrent=%v", serialElapsed, concurrentElapsed)
|
||||
}
|
||||
ratio := float64(concurrentElapsed) / float64(serialElapsed)
|
||||
t.Logf("speedup ratio (concurrent/serial)=%.3f", ratio)
|
||||
}
|
||||
|
||||
func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
orphanA := createTempLog(t, tempDir, "codex-wrapper-5001.log")
|
||||
orphanB := createTempLog(t, tempDir, "codex-wrapper-5002-extra.log")
|
||||
orphanC := createTempLog(t, tempDir, "codex-wrapper-5003-suffix.log")
|
||||
runningPID := 81234
|
||||
runningLog := createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", runningPID))
|
||||
unrelated := createTempLog(t, tempDir, "wrapper.log")
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
return pid == runningPID || pid == os.Getpid()
|
||||
})
|
||||
stubProcessStartTime(t, func(pid int) time.Time {
|
||||
if pid == runningPID || pid == os.Getpid() {
|
||||
return time.Now().Add(-1 * time.Hour)
|
||||
}
|
||||
return time.Time{}
|
||||
})
|
||||
|
||||
codexCommand = createFakeCodexScript(t, "tid-startup", "ok")
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
os.Args = []string{"codex-wrapper", "task"}
|
||||
|
||||
if exit := run(); exit != 0 {
|
||||
t.Fatalf("run() exit=%d, want 0", exit)
|
||||
}
|
||||
|
||||
for _, orphan := range []string{orphanA, orphanB, orphanC} {
|
||||
if _, err := os.Stat(orphan); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected orphan %s to be removed, err=%v", orphan, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(runningLog); err != nil {
|
||||
t.Fatalf("expected running log to remain, err=%v", err)
|
||||
}
|
||||
if _, err := os.Stat(unrelated); err != nil {
|
||||
t.Fatalf("expected unrelated file to remain, err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStartupCleanupConcurrentWrappers(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
const totalLogs = 40
|
||||
for i := 0; i < totalLogs; i++ {
|
||||
createTempLog(t, tempDir, fmt.Sprintf("codex-wrapper-%d.log", 9000+i))
|
||||
}
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
return false
|
||||
})
|
||||
stubProcessStartTime(t, func(int) time.Time { return time.Time{} })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const instances = 5
|
||||
start := make(chan struct{})
|
||||
|
||||
for i := 0; i < instances; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-start
|
||||
runStartupCleanup()
|
||||
}()
|
||||
}
|
||||
|
||||
close(start)
|
||||
wg.Wait()
|
||||
|
||||
matches, err := filepath.Glob(filepath.Join(tempDir, "codex-wrapper-*.log"))
|
||||
if err != nil {
|
||||
t.Fatalf("glob error: %v", err)
|
||||
}
|
||||
if len(matches) != 0 {
|
||||
t.Fatalf("expected all orphan logs to be removed, remaining=%v", matches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
staleA := createTempLog(t, tempDir, "codex-wrapper-2100.log")
|
||||
staleB := createTempLog(t, tempDir, "codex-wrapper-2200-extra.log")
|
||||
keeper := createTempLog(t, tempDir, "codex-wrapper-2300.log")
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
return pid == 2300 || pid == os.Getpid()
|
||||
})
|
||||
stubProcessStartTime(t, func(pid int) time.Time {
|
||||
if pid == 2300 || pid == os.Getpid() {
|
||||
return time.Now().Add(-1 * time.Hour)
|
||||
}
|
||||
return time.Time{}
|
||||
})
|
||||
|
||||
os.Args = []string{"codex-wrapper", "--cleanup"}
|
||||
|
||||
var exitCode int
|
||||
output := captureStdout(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("cleanup exit = %d, want 0", exitCode)
|
||||
}
|
||||
|
||||
// Check that output contains expected counts and file names
|
||||
if !strings.Contains(output, "Cleanup completed") {
|
||||
t.Fatalf("missing 'Cleanup completed' in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "Files scanned: 3") {
|
||||
t.Fatalf("missing 'Files scanned: 3' in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "Files deleted: 2") {
|
||||
t.Fatalf("missing 'Files deleted: 2' in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "Files kept: 1") {
|
||||
t.Fatalf("missing 'Files kept: 1' in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "codex-wrapper-2100.log") || !strings.Contains(output, "codex-wrapper-2200-extra.log") {
|
||||
t.Fatalf("missing deleted file names in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "codex-wrapper-2300.log") {
|
||||
t.Fatalf("missing kept file names in output: %q", output)
|
||||
}
|
||||
|
||||
for _, path := range []string{staleA, staleB} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected %s to be removed, err=%v", path, err)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(keeper); err != nil {
|
||||
t.Fatalf("expected kept log to remain, err=%v", err)
|
||||
}
|
||||
|
||||
currentLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
if _, err := os.Stat(currentLog); err == nil {
|
||||
t.Fatalf("cleanup mode should not create new log file %s", currentLog)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat(%s) unexpected error: %v", currentLog, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCleanupFlagEndToEnd_FailureDoesNotAffectStartup(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
calls := 0
|
||||
cleanupLogsFn = func() (CleanupStats, error) {
|
||||
calls++
|
||||
return CleanupStats{Scanned: 1}, fmt.Errorf("permission denied")
|
||||
}
|
||||
|
||||
os.Args = []string{"codex-wrapper", "--cleanup"}
|
||||
|
||||
var exitCode int
|
||||
errOutput := captureStderr(t, func() {
|
||||
exitCode = run()
|
||||
})
|
||||
|
||||
if exitCode != 1 {
|
||||
t.Fatalf("cleanup failure exit = %d, want 1", exitCode)
|
||||
}
|
||||
if !strings.Contains(errOutput, "Cleanup failed") || !strings.Contains(errOutput, "permission denied") {
|
||||
t.Fatalf("cleanup stderr = %q, want failure message", errOutput)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("cleanup called %d times, want 1", calls)
|
||||
}
|
||||
|
||||
currentLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
|
||||
if _, err := os.Stat(currentLog); err == nil {
|
||||
t.Fatalf("cleanup failure should not create new log file %s", currentLog)
|
||||
} else if !os.IsNotExist(err) {
|
||||
t.Fatalf("stat(%s) unexpected error: %v", currentLog, err)
|
||||
}
|
||||
|
||||
cleanupLogsFn = func() (CleanupStats, error) {
|
||||
return CleanupStats{}, nil
|
||||
}
|
||||
codexCommand = createFakeCodexScript(t, "tid-cleanup-e2e", "ok")
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
os.Args = []string{"codex-wrapper", "post-cleanup task"}
|
||||
|
||||
var normalExit int
|
||||
normalOutput := captureStdout(t, func() {
|
||||
normalExit = run()
|
||||
})
|
||||
|
||||
if normalExit != 0 {
|
||||
t.Fatalf("normal run exit = %d, want 0", normalExit)
|
||||
}
|
||||
if !strings.Contains(normalOutput, "ok") {
|
||||
t.Fatalf("normal run output = %q, want codex output", normalOutput)
|
||||
}
|
||||
}
|
||||
4997
codeagent-wrapper/main_test.go
Normal file
4997
codeagent-wrapper/main_test.go
Normal file
File diff suppressed because it is too large
Load Diff
456
codeagent-wrapper/parser.go
Normal file
456
codeagent-wrapper/parser.go
Normal file
@@ -0,0 +1,456 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// JSONEvent represents a Codex JSON output event
|
||||
type JSONEvent struct {
|
||||
Type string `json:"type"`
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
Item *EventItem `json:"item,omitempty"`
|
||||
}
|
||||
|
||||
// EventItem represents the item field in a JSON event
|
||||
type EventItem struct {
|
||||
Type string `json:"type"`
|
||||
Text interface{} `json:"text"`
|
||||
}
|
||||
|
||||
// ClaudeEvent for Claude stream-json format
|
||||
type ClaudeEvent struct {
|
||||
Type string `json:"type"`
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// GeminiEvent for Gemini stream-json format
|
||||
type GeminiEvent struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Delta bool `json:"delta,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
func parseJSONStream(r io.Reader) (message, threadID string) {
|
||||
return parseJSONStreamWithLog(r, logWarn, logInfo)
|
||||
}
|
||||
|
||||
func parseJSONStreamWithWarn(r io.Reader, warnFn func(string)) (message, threadID string) {
|
||||
return parseJSONStreamWithLog(r, warnFn, logInfo)
|
||||
}
|
||||
|
||||
func parseJSONStreamWithLog(r io.Reader, warnFn func(string), infoFn func(string)) (message, threadID string) {
|
||||
return parseJSONStreamInternal(r, warnFn, infoFn, nil, nil)
|
||||
}
|
||||
|
||||
const (
|
||||
jsonLineReaderSize = 64 * 1024
|
||||
jsonLineMaxBytes = 10 * 1024 * 1024
|
||||
jsonLinePreviewBytes = 256
|
||||
)
|
||||
|
||||
// UnifiedEvent combines all backend event formats into a single structure
|
||||
// to avoid multiple JSON unmarshal operations per event
|
||||
type UnifiedEvent struct {
|
||||
// Common fields
|
||||
Type string `json:"type"`
|
||||
|
||||
// Codex-specific fields
|
||||
ThreadID string `json:"thread_id,omitempty"`
|
||||
Item json.RawMessage `json:"item,omitempty"` // Lazy parse
|
||||
|
||||
// Claude-specific fields
|
||||
Subtype string `json:"subtype,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
Result string `json:"result,omitempty"`
|
||||
|
||||
// Gemini-specific fields
|
||||
Role string `json:"role,omitempty"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Delta *bool `json:"delta,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
|
||||
// Opencode-specific fields (camelCase sessionID)
|
||||
OpencodeSessionID string `json:"sessionID,omitempty"`
|
||||
Part json.RawMessage `json:"part,omitempty"`
|
||||
}
|
||||
|
||||
// OpencodePart represents the part field in opencode events
|
||||
type OpencodePart struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
SessionID string `json:"sessionID,omitempty"`
|
||||
}
|
||||
|
||||
// ItemContent represents the parsed item.text field for Codex events
|
||||
type ItemContent struct {
|
||||
Type string `json:"type"`
|
||||
Text interface{} `json:"text"`
|
||||
}
|
||||
|
||||
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
|
||||
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
|
||||
|
||||
if warnFn == nil {
|
||||
warnFn = func(string) {}
|
||||
}
|
||||
if infoFn == nil {
|
||||
infoFn = func(string) {}
|
||||
}
|
||||
|
||||
notifyMessage := func() {
|
||||
if onMessage != nil {
|
||||
onMessage()
|
||||
}
|
||||
}
|
||||
|
||||
notifyComplete := func() {
|
||||
if onComplete != nil {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
totalEvents := 0
|
||||
|
||||
var (
|
||||
codexMessage string
|
||||
claudeMessage string
|
||||
geminiBuffer strings.Builder
|
||||
opencodeMessage strings.Builder
|
||||
)
|
||||
|
||||
for {
|
||||
line, tooLong, err := readLineWithLimit(reader, jsonLineMaxBytes, jsonLinePreviewBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
warnFn("Read stdout error: " + err.Error())
|
||||
break
|
||||
}
|
||||
|
||||
line = bytes.TrimSpace(line)
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
totalEvents++
|
||||
|
||||
if tooLong {
|
||||
warnFn(fmt.Sprintf("Skipped overlong JSON line (> %d bytes): %s", jsonLineMaxBytes, truncateBytes(line, 100)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Single unmarshal for all backend types
|
||||
var event UnifiedEvent
|
||||
if err := json.Unmarshal(line, &event); err != nil {
|
||||
warnFn(fmt.Sprintf("Failed to parse event: %s", truncateBytes(line, 100)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect backend type by field presence
|
||||
isCodex := event.ThreadID != ""
|
||||
if !isCodex && len(event.Item) > 0 {
|
||||
var itemHeader struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if json.Unmarshal(event.Item, &itemHeader) == nil && itemHeader.Type != "" {
|
||||
isCodex = true
|
||||
}
|
||||
}
|
||||
// Codex-specific event types without thread_id or item
|
||||
if !isCodex && (event.Type == "turn.started" || event.Type == "turn.completed") {
|
||||
isCodex = true
|
||||
}
|
||||
isClaude := event.Subtype != "" || event.Result != ""
|
||||
if !isClaude && event.Type == "result" && event.SessionID != "" && event.Status == "" {
|
||||
isClaude = true
|
||||
}
|
||||
isGemini := (event.Type == "init" && event.SessionID != "") || event.Role != "" || event.Delta != nil || event.Status != ""
|
||||
isOpencode := event.OpencodeSessionID != "" && len(event.Part) > 0
|
||||
|
||||
// Handle Opencode events first (most specific detection)
|
||||
if isOpencode {
|
||||
if threadID == "" {
|
||||
threadID = event.OpencodeSessionID
|
||||
}
|
||||
|
||||
var part OpencodePart
|
||||
if err := json.Unmarshal(event.Part, &part); err != nil {
|
||||
warnFn(fmt.Sprintf("Failed to parse opencode part: %s", err.Error()))
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract sessionID from part if available
|
||||
if part.SessionID != "" && threadID == "" {
|
||||
threadID = part.SessionID
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("Parsed Opencode event #%d type=%s part_type=%s", totalEvents, event.Type, part.Type))
|
||||
|
||||
if event.Type == "text" && part.Text != "" {
|
||||
opencodeMessage.WriteString(part.Text)
|
||||
notifyMessage()
|
||||
}
|
||||
|
||||
if part.Type == "step-finish" && part.Reason == "stop" {
|
||||
notifyComplete()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Codex events
|
||||
if isCodex {
|
||||
var details []string
|
||||
if event.ThreadID != "" {
|
||||
details = append(details, fmt.Sprintf("thread_id=%s", event.ThreadID))
|
||||
}
|
||||
|
||||
if len(details) > 0 {
|
||||
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, event.Type, strings.Join(details, ", ")))
|
||||
} else {
|
||||
infoFn(fmt.Sprintf("Parsed event #%d type=%s", totalEvents, event.Type))
|
||||
}
|
||||
|
||||
switch event.Type {
|
||||
case "thread.started":
|
||||
threadID = event.ThreadID
|
||||
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
|
||||
|
||||
case "thread.completed":
|
||||
if event.ThreadID != "" && threadID == "" {
|
||||
threadID = event.ThreadID
|
||||
}
|
||||
infoFn(fmt.Sprintf("thread.completed event thread_id=%s", event.ThreadID))
|
||||
notifyComplete()
|
||||
|
||||
case "turn.completed":
|
||||
infoFn("turn.completed event")
|
||||
notifyComplete()
|
||||
|
||||
case "item.completed":
|
||||
var itemType string
|
||||
if len(event.Item) > 0 {
|
||||
var itemHeader struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal(event.Item, &itemHeader); err == nil {
|
||||
itemType = itemHeader.Type
|
||||
}
|
||||
}
|
||||
|
||||
if itemType == "agent_message" && len(event.Item) > 0 {
|
||||
// Lazy parse: only parse item content when needed
|
||||
var item ItemContent
|
||||
if err := json.Unmarshal(event.Item, &item); err == nil {
|
||||
normalized := normalizeText(item.Text)
|
||||
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
|
||||
if normalized != "" {
|
||||
codexMessage = normalized
|
||||
notifyMessage()
|
||||
}
|
||||
} else {
|
||||
warnFn(fmt.Sprintf("Failed to parse item content: %s", err.Error()))
|
||||
}
|
||||
} else {
|
||||
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Claude events
|
||||
if isClaude {
|
||||
if event.SessionID != "" && threadID == "" {
|
||||
threadID = event.SessionID
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("Parsed Claude event #%d type=%s subtype=%s result_len=%d", totalEvents, event.Type, event.Subtype, len(event.Result)))
|
||||
|
||||
if event.Result != "" {
|
||||
claudeMessage = event.Result
|
||||
notifyMessage()
|
||||
}
|
||||
|
||||
if event.Type == "result" {
|
||||
notifyComplete()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle Gemini events
|
||||
if isGemini {
|
||||
if event.SessionID != "" && threadID == "" {
|
||||
threadID = event.SessionID
|
||||
}
|
||||
|
||||
if event.Content != "" {
|
||||
geminiBuffer.WriteString(event.Content)
|
||||
}
|
||||
|
||||
if event.Status != "" {
|
||||
notifyMessage()
|
||||
|
||||
if event.Type == "result" && (event.Status == "success" || event.Status == "error" || event.Status == "complete" || event.Status == "failed") {
|
||||
notifyComplete()
|
||||
}
|
||||
}
|
||||
|
||||
delta := false
|
||||
if event.Delta != nil {
|
||||
delta = *event.Delta
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, delta, event.Status, len(event.Content)))
|
||||
continue
|
||||
}
|
||||
|
||||
// Unknown event format from other backends (turn.started/assistant/user); ignore.
|
||||
continue
|
||||
}
|
||||
|
||||
switch {
|
||||
case opencodeMessage.Len() > 0:
|
||||
message = opencodeMessage.String()
|
||||
case geminiBuffer.Len() > 0:
|
||||
message = geminiBuffer.String()
|
||||
case claudeMessage != "":
|
||||
message = claudeMessage
|
||||
default:
|
||||
message = codexMessage
|
||||
}
|
||||
|
||||
infoFn(fmt.Sprintf("parseJSONStream completed: events=%d, message_len=%d, thread_id_found=%t", totalEvents, len(message), threadID != ""))
|
||||
return message, threadID
|
||||
}
|
||||
|
||||
func hasKey(m map[string]json.RawMessage, key string) bool {
|
||||
_, ok := m[key]
|
||||
return ok
|
||||
}
|
||||
|
||||
func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
|
||||
var buffered bytes.Buffer
|
||||
|
||||
if decoder != nil {
|
||||
if buf := decoder.Buffered(); buf != nil {
|
||||
_, _ = buffered.ReadFrom(buf)
|
||||
}
|
||||
}
|
||||
|
||||
line, err := reader.ReadBytes('\n')
|
||||
buffered.Write(line)
|
||||
|
||||
data := buffered.Bytes()
|
||||
newline := bytes.IndexByte(data, '\n')
|
||||
if newline == -1 {
|
||||
return reader, err
|
||||
}
|
||||
|
||||
remaining := data[newline+1:]
|
||||
if len(remaining) == 0 {
|
||||
return reader, err
|
||||
}
|
||||
|
||||
return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err
|
||||
}
|
||||
|
||||
func readLineWithLimit(r *bufio.Reader, maxBytes int, previewBytes int) (line []byte, tooLong bool, err error) {
|
||||
if r == nil {
|
||||
return nil, false, errors.New("reader is nil")
|
||||
}
|
||||
if maxBytes <= 0 {
|
||||
return nil, false, errors.New("maxBytes must be > 0")
|
||||
}
|
||||
if previewBytes < 0 {
|
||||
previewBytes = 0
|
||||
}
|
||||
|
||||
part, isPrefix, err := r.ReadLine()
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if !isPrefix {
|
||||
if len(part) > maxBytes {
|
||||
return part[:min(len(part), previewBytes)], true, nil
|
||||
}
|
||||
return part, false, nil
|
||||
}
|
||||
|
||||
preview := make([]byte, 0, min(previewBytes, len(part)))
|
||||
if previewBytes > 0 {
|
||||
preview = append(preview, part[:min(previewBytes, len(part))]...)
|
||||
}
|
||||
|
||||
buf := make([]byte, 0, min(maxBytes, len(part)*2))
|
||||
total := 0
|
||||
if len(part) > maxBytes {
|
||||
tooLong = true
|
||||
} else {
|
||||
buf = append(buf, part...)
|
||||
total = len(part)
|
||||
}
|
||||
|
||||
for isPrefix {
|
||||
part, isPrefix, err = r.ReadLine()
|
||||
if err != nil {
|
||||
return nil, tooLong, err
|
||||
}
|
||||
|
||||
if previewBytes > 0 && len(preview) < previewBytes {
|
||||
preview = append(preview, part[:min(previewBytes-len(preview), len(part))]...)
|
||||
}
|
||||
|
||||
if !tooLong {
|
||||
if total+len(part) > maxBytes {
|
||||
tooLong = true
|
||||
continue
|
||||
}
|
||||
buf = append(buf, part...)
|
||||
total += len(part)
|
||||
}
|
||||
}
|
||||
|
||||
if tooLong {
|
||||
return preview, true, nil
|
||||
}
|
||||
return buf, false, nil
|
||||
}
|
||||
|
||||
func truncateBytes(b []byte, maxLen int) string {
|
||||
if len(b) <= maxLen {
|
||||
return string(b)
|
||||
}
|
||||
if maxLen < 0 {
|
||||
return ""
|
||||
}
|
||||
return string(b[:maxLen]) + "..."
|
||||
}
|
||||
|
||||
func normalizeText(text interface{}) string {
|
||||
switch v := text.(type) {
|
||||
case string:
|
||||
return v
|
||||
case []interface{}:
|
||||
var sb strings.Builder
|
||||
for _, item := range v {
|
||||
if s, ok := item.(string); ok {
|
||||
sb.WriteString(s)
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
50
codeagent-wrapper/parser_opencode_test.go
Normal file
50
codeagent-wrapper/parser_opencode_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJSONStream_Opencode(t *testing.T) {
|
||||
input := `{"type":"step_start","timestamp":1768187730683,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb0339afa001NTqoJ2NS8x91zP","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"step-start","snapshot":"904f0fd58c125b79e60f0993e38f9d9f6200bf47"}}
|
||||
{"type":"text","timestamp":1768187744432,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb0339cb5001QDd0Lh0PzFZpa3","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"text","text":"Hello from opencode"}}
|
||||
{"type":"step_finish","timestamp":1768187744471,"sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","part":{"id":"prt_bb033d0af0019VRZzpO2OVW1na","sessionID":"ses_44fced3c7ffe83sZpzY1rlQka3","messageID":"msg_bb033866f0011oZxTqvfy0TKtS","type":"step-finish","reason":"stop","snapshot":"904f0fd58c125b79e60f0993e38f9d9f6200bf47","cost":0}}`
|
||||
|
||||
message, threadID := parseJSONStream(strings.NewReader(input))
|
||||
|
||||
if threadID != "ses_44fced3c7ffe83sZpzY1rlQka3" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "ses_44fced3c7ffe83sZpzY1rlQka3")
|
||||
}
|
||||
if message != "Hello from opencode" {
|
||||
t.Errorf("message = %q, want %q", message, "Hello from opencode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONStream_Opencode_MultipleTextEvents(t *testing.T) {
|
||||
input := `{"type":"text","sessionID":"ses_123","part":{"type":"text","text":"Part 1"}}
|
||||
{"type":"text","sessionID":"ses_123","part":{"type":"text","text":" Part 2"}}
|
||||
{"type":"step_finish","sessionID":"ses_123","part":{"type":"step-finish","reason":"stop"}}`
|
||||
|
||||
message, threadID := parseJSONStream(strings.NewReader(input))
|
||||
|
||||
if threadID != "ses_123" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "ses_123")
|
||||
}
|
||||
if message != "Part 1 Part 2" {
|
||||
t.Errorf("message = %q, want %q", message, "Part 1 Part 2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseJSONStream_Opencode_NoStopReason(t *testing.T) {
|
||||
input := `{"type":"text","sessionID":"ses_456","part":{"type":"text","text":"Content"}}
|
||||
{"type":"step_finish","sessionID":"ses_456","part":{"type":"step-finish","reason":"tool-calls"}}`
|
||||
|
||||
message, threadID := parseJSONStream(strings.NewReader(input))
|
||||
|
||||
if threadID != "ses_456" {
|
||||
t.Errorf("threadID = %q, want %q", threadID, "ses_456")
|
||||
}
|
||||
if message != "Content" {
|
||||
t.Errorf("message = %q, want %q", message, "Content")
|
||||
}
|
||||
}
|
||||
31
codeagent-wrapper/parser_token_too_long_test.go
Normal file
31
codeagent-wrapper/parser_token_too_long_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseJSONStream_SkipsOverlongLineAndContinues(t *testing.T) {
|
||||
// Exceed the 10MB bufio.Scanner limit in parseJSONStreamInternal.
|
||||
tooLong := strings.Repeat("a", 11*1024*1024)
|
||||
|
||||
input := strings.Join([]string{
|
||||
`{"type":"item.completed","item":{"type":"other_type","text":"` + tooLong + `"}}`,
|
||||
`{"type":"thread.started","thread_id":"t-1"}`,
|
||||
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
|
||||
}, "\n")
|
||||
|
||||
var warns []string
|
||||
warnFn := func(msg string) { warns = append(warns, msg) }
|
||||
|
||||
gotMessage, gotThreadID := parseJSONStreamInternal(strings.NewReader(input), warnFn, nil, nil, nil)
|
||||
if gotMessage != "ok" {
|
||||
t.Fatalf("message=%q, want %q (warns=%v)", gotMessage, "ok", warns)
|
||||
}
|
||||
if gotThreadID != "t-1" {
|
||||
t.Fatalf("threadID=%q, want %q (warns=%v)", gotThreadID, "t-1", warns)
|
||||
}
|
||||
if len(warns) == 0 || !strings.Contains(warns[0], "Skipped overlong JSON line") {
|
||||
t.Fatalf("expected warning about overlong JSON line, got %v", warns)
|
||||
}
|
||||
}
|
||||
32
codeagent-wrapper/parser_unknown_event_test.go
Normal file
32
codeagent-wrapper/parser_unknown_event_test.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBackendParseJSONStream_UnknownEventsAreSilent(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
`{"type":"turn.started"}`,
|
||||
`{"type":"assistant","text":"hi"}`,
|
||||
`{"type":"user","text":"yo"}`,
|
||||
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
|
||||
}, "\n")
|
||||
|
||||
var infos []string
|
||||
infoFn := func(msg string) { infos = append(infos, msg) }
|
||||
|
||||
message, threadID := parseJSONStreamInternal(strings.NewReader(input), nil, infoFn, nil, nil)
|
||||
if message != "ok" {
|
||||
t.Fatalf("message=%q, want %q (infos=%v)", message, "ok", infos)
|
||||
}
|
||||
if threadID != "" {
|
||||
t.Fatalf("threadID=%q, want empty (infos=%v)", threadID, infos)
|
||||
}
|
||||
|
||||
for _, msg := range infos {
|
||||
if strings.Contains(msg, "Agent event:") {
|
||||
t.Fatalf("unexpected log for unknown event: %q", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
217
codeagent-wrapper/process_check_test.go
Normal file
217
codeagent-wrapper/process_check_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
//go:build unix || darwin || linux
|
||||
// +build unix darwin linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsProcessRunning(t *testing.T) {
|
||||
t.Run("current process", func(t *testing.T) {
|
||||
if !isProcessRunning(os.Getpid()) {
|
||||
t.Fatalf("expected current process (pid=%d) to be running", os.Getpid())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fake pid", func(t *testing.T) {
|
||||
const nonexistentPID = 1 << 30
|
||||
if isProcessRunning(nonexistentPID) {
|
||||
t.Fatalf("expected pid %d to be reported as not running", nonexistentPID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("terminated process", func(t *testing.T) {
|
||||
pid := exitedProcessPID(t)
|
||||
if isProcessRunning(pid) {
|
||||
t.Fatalf("expected exited child process (pid=%d) to be reported as not running", pid)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("boundary values", func(t *testing.T) {
|
||||
if isProcessRunning(0) {
|
||||
t.Fatalf("pid 0 should never be treated as running")
|
||||
}
|
||||
if isProcessRunning(-42) {
|
||||
t.Fatalf("negative pid should never be treated as running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find process error", func(t *testing.T) {
|
||||
original := findProcess
|
||||
defer func() { findProcess = original }()
|
||||
|
||||
mockErr := errors.New("findProcess failure")
|
||||
findProcess = func(pid int) (*os.Process, error) {
|
||||
return nil, mockErr
|
||||
}
|
||||
|
||||
if isProcessRunning(1234) {
|
||||
t.Fatalf("expected false when os.FindProcess fails")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func exitedProcessPID(t *testing.T) int {
|
||||
t.Helper()
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("cmd", "/c", "exit 0")
|
||||
} else {
|
||||
cmd = exec.Command("sh", "-c", "exit 0")
|
||||
}
|
||||
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start helper process: %v", err)
|
||||
}
|
||||
pid := cmd.Process.Pid
|
||||
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("helper process did not exit cleanly: %v", err)
|
||||
}
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return pid
|
||||
}
|
||||
|
||||
func TestRunProcessCheckSmoke(t *testing.T) {
|
||||
t.Run("current process", func(t *testing.T) {
|
||||
if !isProcessRunning(os.Getpid()) {
|
||||
t.Fatalf("expected current process (pid=%d) to be running", os.Getpid())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fake pid", func(t *testing.T) {
|
||||
const nonexistentPID = 1 << 30
|
||||
if isProcessRunning(nonexistentPID) {
|
||||
t.Fatalf("expected pid %d to be reported as not running", nonexistentPID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("boundary values", func(t *testing.T) {
|
||||
if isProcessRunning(0) {
|
||||
t.Fatalf("pid 0 should never be treated as running")
|
||||
}
|
||||
if isProcessRunning(-42) {
|
||||
t.Fatalf("negative pid should never be treated as running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("find process error", func(t *testing.T) {
|
||||
original := findProcess
|
||||
defer func() { findProcess = original }()
|
||||
|
||||
mockErr := errors.New("findProcess failure")
|
||||
findProcess = func(pid int) (*os.Process, error) {
|
||||
return nil, mockErr
|
||||
}
|
||||
|
||||
if isProcessRunning(1234) {
|
||||
t.Fatalf("expected false when os.FindProcess fails")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetProcessStartTimeReadsProcStat(t *testing.T) {
|
||||
pid := 4321
|
||||
boot := time.Unix(1_710_000_000, 0)
|
||||
startTicks := uint64(4500)
|
||||
|
||||
statFields := make([]string, 25)
|
||||
for i := range statFields {
|
||||
statFields[i] = strconv.Itoa(i + 1)
|
||||
}
|
||||
statFields[19] = strconv.FormatUint(startTicks, 10)
|
||||
statContent := fmt.Sprintf("%d (%s) %s", pid, "cmd with space", strings.Join(statFields, " "))
|
||||
|
||||
stubReadFile(t, func(path string) ([]byte, error) {
|
||||
switch path {
|
||||
case fmt.Sprintf("/proc/%d/stat", pid):
|
||||
return []byte(statContent), nil
|
||||
case "/proc/stat":
|
||||
return []byte(fmt.Sprintf("cpu 0 0 0 0\nbtime %d\n", boot.Unix())), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
})
|
||||
|
||||
got := getProcessStartTime(pid)
|
||||
want := boot.Add(time.Duration(startTicks/100) * time.Second)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("getProcessStartTime() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessStartTimeInvalidData(t *testing.T) {
|
||||
pid := 99
|
||||
stubReadFile(t, func(path string) ([]byte, error) {
|
||||
switch path {
|
||||
case fmt.Sprintf("/proc/%d/stat", pid):
|
||||
return []byte("garbage"), nil
|
||||
case "/proc/stat":
|
||||
return []byte("btime not-a-number\n"), nil
|
||||
default:
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
})
|
||||
|
||||
if got := getProcessStartTime(pid); !got.IsZero() {
|
||||
t.Fatalf("invalid /proc data should return zero time, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBootTimeParsesBtime(t *testing.T) {
|
||||
const bootSec = 1_711_111_111
|
||||
stubReadFile(t, func(path string) ([]byte, error) {
|
||||
if path != "/proc/stat" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
content := fmt.Sprintf("intr 0\nbtime %d\n", bootSec)
|
||||
return []byte(content), nil
|
||||
})
|
||||
|
||||
got := getBootTime()
|
||||
want := time.Unix(bootSec, 0)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("getBootTime() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBootTimeInvalidData(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
content string
|
||||
}{
|
||||
{"missing", "cpu 0 0 0 0"},
|
||||
{"malformed", "btime abc"},
|
||||
}
|
||||
|
||||
for _, tt := range cases {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stubReadFile(t, func(string) ([]byte, error) {
|
||||
return []byte(tt.content), nil
|
||||
})
|
||||
if got := getBootTime(); !got.IsZero() {
|
||||
t.Fatalf("getBootTime() unexpected value for %s: %v", tt.name, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func stubReadFile(t *testing.T, fn func(string) ([]byte, error)) {
|
||||
t.Helper()
|
||||
original := readFileFn
|
||||
readFileFn = fn
|
||||
t.Cleanup(func() {
|
||||
readFileFn = original
|
||||
})
|
||||
}
|
||||
104
codeagent-wrapper/process_check_unix.go
Normal file
104
codeagent-wrapper/process_check_unix.go
Normal file
@@ -0,0 +1,104 @@
|
||||
//go:build unix || darwin || linux
|
||||
// +build unix darwin linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var findProcess = os.FindProcess
|
||||
var readFileFn = os.ReadFile
|
||||
|
||||
// isProcessRunning returns true if a process with the given pid is running on Unix-like systems.
|
||||
func isProcessRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
proc, err := findProcess(pid)
|
||||
if err != nil || proc == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
err = proc.Signal(syscall.Signal(0))
|
||||
if err != nil && (errors.Is(err, syscall.ESRCH) || errors.Is(err, os.ErrProcessDone)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// getProcessStartTime returns the start time of a process on Unix-like systems.
|
||||
// Returns zero time if the start time cannot be determined.
|
||||
func getProcessStartTime(pid int) time.Time {
|
||||
if pid <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Read /proc/<pid>/stat to get process start time
|
||||
statPath := fmt.Sprintf("/proc/%d/stat", pid)
|
||||
data, err := readFileFn(statPath)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Parse stat file: fields are space-separated, but comm (field 2) can contain spaces
|
||||
// Find the last ')' to skip comm field safely
|
||||
content := string(data)
|
||||
lastParen := strings.LastIndex(content, ")")
|
||||
if lastParen == -1 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
fields := strings.Fields(content[lastParen+1:])
|
||||
if len(fields) < 20 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Field 22 (index 19 after comm) is starttime in clock ticks since boot
|
||||
startTicks, err := strconv.ParseUint(fields[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Get system boot time
|
||||
bootTime := getBootTime()
|
||||
if bootTime.IsZero() {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// Convert ticks to duration (typically 100 ticks/sec on most systems)
|
||||
ticksPerSec := uint64(100) // sysconf(_SC_CLK_TCK), typically 100
|
||||
startTime := bootTime.Add(time.Duration(startTicks/ticksPerSec) * time.Second)
|
||||
|
||||
return startTime
|
||||
}
|
||||
|
||||
// getBootTime returns the system boot time by reading /proc/stat.
|
||||
func getBootTime() time.Time {
|
||||
data, err := readFileFn("/proc/stat")
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
lines := strings.Split(string(data), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.HasPrefix(line, "btime ") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
bootSec, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err == nil {
|
||||
return time.Unix(bootSec, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return time.Time{}
|
||||
}
|
||||
87
codeagent-wrapper/process_check_windows.go
Normal file
87
codeagent-wrapper/process_check_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
processQueryLimitedInformation = 0x1000
|
||||
stillActive = 259 // STILL_ACTIVE exit code
|
||||
)
|
||||
|
||||
var (
|
||||
findProcess = os.FindProcess
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
getProcessTimes = kernel32.NewProc("GetProcessTimes")
|
||||
fileTimeToUnixFn = fileTimeToUnix
|
||||
)
|
||||
|
||||
// isProcessRunning returns true if a process with the given pid is running on Windows.
|
||||
func isProcessRunning(pid int) bool {
|
||||
if pid <= 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, err := findProcess(pid); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid))
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.ERROR_ACCESS_DENIED) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
defer syscall.CloseHandle(handle)
|
||||
|
||||
var exitCode uint32
|
||||
if err := syscall.GetExitCodeProcess(handle, &exitCode); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return exitCode == stillActive
|
||||
}
|
||||
|
||||
// getProcessStartTime returns the start time of a process on Windows.
|
||||
// Returns zero time if the start time cannot be determined.
|
||||
func getProcessStartTime(pid int) time.Time {
|
||||
if pid <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
handle, err := syscall.OpenProcess(processQueryLimitedInformation, false, uint32(pid))
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
defer syscall.CloseHandle(handle)
|
||||
|
||||
var creationTime, exitTime, kernelTime, userTime syscall.Filetime
|
||||
ret, _, _ := getProcessTimes.Call(
|
||||
uintptr(handle),
|
||||
uintptr(unsafe.Pointer(&creationTime)),
|
||||
uintptr(unsafe.Pointer(&exitTime)),
|
||||
uintptr(unsafe.Pointer(&kernelTime)),
|
||||
uintptr(unsafe.Pointer(&userTime)),
|
||||
)
|
||||
|
||||
if ret == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
return fileTimeToUnixFn(creationTime)
|
||||
}
|
||||
|
||||
// fileTimeToUnix converts Windows FILETIME to Unix time.
|
||||
func fileTimeToUnix(ft syscall.Filetime) time.Time {
|
||||
// FILETIME is 100-nanosecond intervals since January 1, 1601 UTC
|
||||
nsec := ft.Nanoseconds()
|
||||
return time.Unix(0, nsec)
|
||||
}
|
||||
64
codeagent-wrapper/process_check_windows_test.go
Normal file
64
codeagent-wrapper/process_check_windows_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestIsProcessRunning(t *testing.T) {
|
||||
t.Run("boundary values", func(t *testing.T) {
|
||||
if isProcessRunning(0) {
|
||||
t.Fatalf("expected pid 0 to be reported as not running")
|
||||
}
|
||||
if isProcessRunning(-1) {
|
||||
t.Fatalf("expected pid -1 to be reported as not running")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("current process", func(t *testing.T) {
|
||||
if !isProcessRunning(os.Getpid()) {
|
||||
t.Fatalf("expected current process (pid=%d) to be running", os.Getpid())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fake pid", func(t *testing.T) {
|
||||
const nonexistentPID = 1 << 30
|
||||
if isProcessRunning(nonexistentPID) {
|
||||
t.Fatalf("expected pid %d to be reported as not running", nonexistentPID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetProcessStartTimeReadsProcStat(t *testing.T) {
|
||||
start := getProcessStartTime(os.Getpid())
|
||||
if start.IsZero() {
|
||||
t.Fatalf("expected non-zero start time for current process")
|
||||
}
|
||||
if start.After(time.Now().Add(5 * time.Second)) {
|
||||
t.Fatalf("start time is unexpectedly in the future: %v", start)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetProcessStartTimeInvalidData(t *testing.T) {
|
||||
if !getProcessStartTime(0).IsZero() {
|
||||
t.Fatalf("expected zero time for pid 0")
|
||||
}
|
||||
if !getProcessStartTime(-1).IsZero() {
|
||||
t.Fatalf("expected zero time for negative pid")
|
||||
}
|
||||
if !getProcessStartTime(1 << 30).IsZero() {
|
||||
t.Fatalf("expected zero time for non-existent pid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBootTimeParsesBtime(t *testing.T) {
|
||||
t.Skip("getBootTime is only implemented on Unix-like systems")
|
||||
}
|
||||
|
||||
func TestGetBootTimeInvalidData(t *testing.T) {
|
||||
t.Skip("getBootTime is only implemented on Unix-like systems")
|
||||
}
|
||||
163
codeagent-wrapper/prompt_file_test.go
Normal file
163
codeagent-wrapper/prompt_file_test.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWrapTaskWithAgentPrompt(t *testing.T) {
|
||||
got := wrapTaskWithAgentPrompt("P", "do")
|
||||
want := "<agent-prompt>\nP\n</agent-prompt>\n\ndo"
|
||||
if got != want {
|
||||
t.Fatalf("wrapTaskWithAgentPrompt mismatch:\n got=%q\nwant=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_EmptyPath(t *testing.T) {
|
||||
for _, allowOutside := range []bool{false, true} {
|
||||
got, err := readAgentPromptFile(" ", allowOutside)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error (allowOutside=%v): %v", allowOutside, err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Fatalf("expected empty result (allowOutside=%v), got %q", allowOutside, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_ExplicitAbsolutePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("LINE1\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := readAgentPromptFile(path, true)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "LINE1" {
|
||||
t.Fatalf("got %q, want %q", got, "LINE1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_ExplicitTildeExpansion(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
path := filepath.Join(home, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("P\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := readAgentPromptFile("~/prompt.md", true)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "P" {
|
||||
t.Fatalf("got %q, want %q", got, "P")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedAllowsClaudeDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(claudeDir, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("OK\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
got, err := readAgentPromptFile("~/.claude/prompt.md", false)
|
||||
if err != nil {
|
||||
t.Fatalf("readAgentPromptFile error: %v", err)
|
||||
}
|
||||
if got != "OK" {
|
||||
t.Fatalf("got %q, want %q", got, "OK")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedRejectsOutsideClaudeDir(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
path := filepath.Join(home, "prompt.md")
|
||||
if err := os.WriteFile(path, []byte("NO\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readAgentPromptFile("~/prompt.md", false); err == nil {
|
||||
t.Fatalf("expected error for prompt file outside ~/.claude, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_RestrictedRejectsTraversal(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
path := filepath.Join(home, "secret.md")
|
||||
if err := os.WriteFile(path, []byte("SECRET\n"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readAgentPromptFile("~/.claude/../secret.md", false); err == nil {
|
||||
t.Fatalf("expected traversal to be rejected, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_NotFound(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
_, err := readAgentPromptFile("~/.claude/missing.md", false)
|
||||
if err == nil || !os.IsNotExist(err) {
|
||||
t.Fatalf("expected not-exist error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadAgentPromptFile_PermissionDenied(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("chmod-based permission test is not reliable on Windows")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
claudeDir := filepath.Join(home, ".claude")
|
||||
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
path := filepath.Join(claudeDir, "private.md")
|
||||
if err := os.WriteFile(path, []byte("PRIVATE\n"), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o000); err != nil {
|
||||
t.Fatalf("Chmod: %v", err)
|
||||
}
|
||||
|
||||
_, err := readAgentPromptFile("~/.claude/private.md", false)
|
||||
if err == nil {
|
||||
t.Fatalf("expected permission error, got nil")
|
||||
}
|
||||
if !os.IsPermission(err) && !strings.Contains(strings.ToLower(err.Error()), "permission") {
|
||||
t.Fatalf("expected permission denied, got: %v", err)
|
||||
}
|
||||
}
|
||||
16
codeagent-wrapper/signal_unix.go
Normal file
16
codeagent-wrapper/signal_unix.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build unix || darwin || linux
|
||||
// +build unix darwin linux
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// sendTermSignal sends SIGTERM for graceful shutdown on Unix.
|
||||
func sendTermSignal(proc processHandle) error {
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
return proc.Signal(syscall.SIGTERM)
|
||||
}
|
||||
87
codeagent-wrapper/signal_windows.go
Normal file
87
codeagent-wrapper/signal_windows.go
Normal file
@@ -0,0 +1,87 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// sendTermSignal on Windows directly kills the process.
|
||||
// SIGTERM is not supported on Windows.
|
||||
func sendTermSignal(proc processHandle) error {
|
||||
if proc == nil {
|
||||
return nil
|
||||
}
|
||||
pid := proc.Pid()
|
||||
if pid > 0 {
|
||||
// Kill the whole process tree to avoid leaving inheriting child processes around.
|
||||
// This also helps prevent exec.Cmd.Wait() from blocking on stderr/stdout pipes held open by children.
|
||||
taskkill := "taskkill"
|
||||
if root := os.Getenv("SystemRoot"); root != "" {
|
||||
taskkill = filepath.Join(root, "System32", "taskkill.exe")
|
||||
}
|
||||
cmd := exec.Command(taskkill, "/PID", strconv.Itoa(pid), "/T", "/F")
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err == nil {
|
||||
return nil
|
||||
}
|
||||
if err := killProcessTree(pid); err == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return proc.Kill()
|
||||
}
|
||||
|
||||
func killProcessTree(pid int) error {
|
||||
if pid <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
wmic := "wmic"
|
||||
if root := os.Getenv("SystemRoot"); root != "" {
|
||||
wmic = filepath.Join(root, "System32", "wbem", "WMIC.exe")
|
||||
}
|
||||
|
||||
queryChildren := "(ParentProcessId=" + strconv.Itoa(pid) + ")"
|
||||
listCmd := exec.Command(wmic, "process", "where", queryChildren, "get", "ProcessId", "/VALUE")
|
||||
listCmd.Stderr = io.Discard
|
||||
out, err := listCmd.Output()
|
||||
if err == nil {
|
||||
for _, childPID := range parseWMICPIDs(out) {
|
||||
_ = killProcessTree(childPID)
|
||||
}
|
||||
}
|
||||
|
||||
querySelf := "(ProcessId=" + strconv.Itoa(pid) + ")"
|
||||
termCmd := exec.Command(wmic, "process", "where", querySelf, "call", "terminate")
|
||||
termCmd.Stdout = io.Discard
|
||||
termCmd.Stderr = io.Discard
|
||||
if termErr := termCmd.Run(); termErr != nil && err == nil {
|
||||
err = termErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func parseWMICPIDs(out []byte) []int {
|
||||
const prefix = "ProcessId="
|
||||
var pids []int
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimSpace(strings.TrimPrefix(line, prefix)))
|
||||
if err != nil || n <= 0 {
|
||||
continue
|
||||
}
|
||||
pids = append(pids, n)
|
||||
}
|
||||
return pids
|
||||
}
|
||||
682
codeagent-wrapper/utils.go
Normal file
682
codeagent-wrapper/utils.go
Normal file
@@ -0,0 +1,682 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func resolveTimeout() int {
|
||||
raw := os.Getenv("CODEX_TIMEOUT")
|
||||
if raw == "" {
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
parsed, err := strconv.Atoi(raw)
|
||||
if err != nil || parsed <= 0 {
|
||||
logWarn(fmt.Sprintf("Invalid CODEX_TIMEOUT '%s', falling back to %ds", raw, defaultTimeout))
|
||||
return defaultTimeout
|
||||
}
|
||||
|
||||
if parsed > 10000 {
|
||||
return parsed / 1000
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func readPipedTask() (string, error) {
|
||||
if isTerminal() {
|
||||
logInfo("Stdin is tty, skipping pipe read")
|
||||
return "", nil
|
||||
}
|
||||
logInfo("Reading from stdin pipe...")
|
||||
data, err := io.ReadAll(stdinReader)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read stdin: %w", err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
logInfo("Stdin pipe returned empty data")
|
||||
return "", nil
|
||||
}
|
||||
logInfo(fmt.Sprintf("Read %d bytes from stdin pipe", len(data)))
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func shouldUseStdin(taskText string, piped bool) bool {
|
||||
if piped {
|
||||
return true
|
||||
}
|
||||
if len(taskText) > 800 {
|
||||
return true
|
||||
}
|
||||
return strings.IndexAny(taskText, stdinSpecialChars) >= 0
|
||||
}
|
||||
|
||||
func defaultIsTerminal() bool {
|
||||
fi, err := os.Stdin.Stat()
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
return (fi.Mode() & os.ModeCharDevice) != 0
|
||||
}
|
||||
|
||||
func isTerminal() bool {
|
||||
return isTerminalFn()
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if val := os.Getenv(key); val != "" {
|
||||
return val
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
type logWriter struct {
|
||||
prefix string
|
||||
maxLen int
|
||||
buf bytes.Buffer
|
||||
dropped bool
|
||||
}
|
||||
|
||||
func newLogWriter(prefix string, maxLen int) *logWriter {
|
||||
if maxLen <= 0 {
|
||||
maxLen = codexLogLineLimit
|
||||
}
|
||||
return &logWriter{prefix: prefix, maxLen: maxLen}
|
||||
}
|
||||
|
||||
func (lw *logWriter) Write(p []byte) (int, error) {
|
||||
if lw == nil {
|
||||
return len(p), nil
|
||||
}
|
||||
total := len(p)
|
||||
for len(p) > 0 {
|
||||
if idx := bytes.IndexByte(p, '\n'); idx >= 0 {
|
||||
lw.writeLimited(p[:idx])
|
||||
lw.logLine(true)
|
||||
p = p[idx+1:]
|
||||
continue
|
||||
}
|
||||
lw.writeLimited(p)
|
||||
break
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (lw *logWriter) Flush() {
|
||||
if lw == nil || lw.buf.Len() == 0 {
|
||||
return
|
||||
}
|
||||
lw.logLine(false)
|
||||
}
|
||||
|
||||
func (lw *logWriter) logLine(force bool) {
|
||||
if lw == nil {
|
||||
return
|
||||
}
|
||||
line := lw.buf.String()
|
||||
dropped := lw.dropped
|
||||
lw.dropped = false
|
||||
lw.buf.Reset()
|
||||
if line == "" && !force {
|
||||
return
|
||||
}
|
||||
if lw.maxLen > 0 {
|
||||
if dropped {
|
||||
if lw.maxLen > 3 {
|
||||
line = line[:min(len(line), lw.maxLen-3)] + "..."
|
||||
} else {
|
||||
line = line[:min(len(line), lw.maxLen)]
|
||||
}
|
||||
} else if len(line) > lw.maxLen {
|
||||
cutoff := lw.maxLen
|
||||
if cutoff > 3 {
|
||||
line = line[:cutoff-3] + "..."
|
||||
} else {
|
||||
line = line[:cutoff]
|
||||
}
|
||||
}
|
||||
}
|
||||
logInfo(lw.prefix + line)
|
||||
}
|
||||
|
||||
func (lw *logWriter) writeLimited(p []byte) {
|
||||
if lw == nil || len(p) == 0 {
|
||||
return
|
||||
}
|
||||
if lw.maxLen <= 0 {
|
||||
lw.buf.Write(p)
|
||||
return
|
||||
}
|
||||
|
||||
remaining := lw.maxLen - lw.buf.Len()
|
||||
if remaining <= 0 {
|
||||
lw.dropped = true
|
||||
return
|
||||
}
|
||||
if len(p) <= remaining {
|
||||
lw.buf.Write(p)
|
||||
return
|
||||
}
|
||||
lw.buf.Write(p[:remaining])
|
||||
lw.dropped = true
|
||||
}
|
||||
|
||||
type tailBuffer struct {
|
||||
limit int
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *tailBuffer) Write(p []byte) (int, error) {
|
||||
if b.limit <= 0 {
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
if len(p) >= b.limit {
|
||||
b.data = append(b.data[:0], p[len(p)-b.limit:]...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
total := len(b.data) + len(p)
|
||||
if total <= b.limit {
|
||||
b.data = append(b.data, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
overflow := total - b.limit
|
||||
b.data = append(b.data[overflow:], p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func (b *tailBuffer) String() string {
|
||||
return string(b.data)
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
if maxLen < 0 {
|
||||
return ""
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// safeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
|
||||
func safeTruncate(s string, maxLen int) string {
|
||||
if maxLen <= 0 || s == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
runes := []rune(s)
|
||||
if len(runes) <= maxLen {
|
||||
return s
|
||||
}
|
||||
|
||||
if maxLen < 4 {
|
||||
return string(runes[:1])
|
||||
}
|
||||
|
||||
cutoff := maxLen - 3
|
||||
if cutoff <= 0 {
|
||||
return string(runes[:1])
|
||||
}
|
||||
if len(runes) <= cutoff {
|
||||
return s
|
||||
}
|
||||
return string(runes[:cutoff]) + "..."
|
||||
}
|
||||
|
||||
// sanitizeOutput removes ANSI escape sequences and control characters.
|
||||
func sanitizeOutput(s string) string {
|
||||
var result strings.Builder
|
||||
inEscape := false
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\x1b' && i+1 < len(s) && s[i+1] == '[' {
|
||||
inEscape = true
|
||||
i++ // skip '['
|
||||
continue
|
||||
}
|
||||
if inEscape {
|
||||
if (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= 'a' && s[i] <= 'z') {
|
||||
inEscape = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
// Keep printable chars and common whitespace.
|
||||
if s[i] >= 32 || s[i] == '\n' || s[i] == '\t' {
|
||||
result.WriteByte(s[i])
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func hello() string {
|
||||
return "hello world"
|
||||
}
|
||||
|
||||
func greet(name string) string {
|
||||
return "hello " + name
|
||||
}
|
||||
|
||||
func farewell(name string) string {
|
||||
return "goodbye " + name
|
||||
}
|
||||
|
||||
// extractCoverageFromLines extracts coverage from pre-split lines.
|
||||
func extractCoverageFromLines(lines []string) string {
|
||||
if len(lines) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
end := len(lines)
|
||||
for end > 0 && strings.TrimSpace(lines[end-1]) == "" {
|
||||
end--
|
||||
}
|
||||
|
||||
if end == 1 {
|
||||
trimmed := strings.TrimSpace(lines[0])
|
||||
if strings.HasSuffix(trimmed, "%") {
|
||||
if num, err := strconv.ParseFloat(strings.TrimSuffix(trimmed, "%"), 64); err == nil && num >= 0 && num <= 100 {
|
||||
return trimmed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
coverageKeywords := []string{"file", "stmt", "branch", "line", "coverage", "total"}
|
||||
|
||||
for _, line := range lines[:end] {
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
hasKeyword := false
|
||||
tokens := strings.FieldsFunc(lower, func(r rune) bool { return r < 'a' || r > 'z' })
|
||||
for _, token := range tokens {
|
||||
for _, kw := range coverageKeywords {
|
||||
if strings.HasPrefix(token, kw) {
|
||||
hasKeyword = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasKeyword {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasKeyword {
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(line, "%") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract percentage pattern: number followed by %
|
||||
for i := 0; i < len(line); i++ {
|
||||
if line[i] == '%' && i > 0 {
|
||||
// Walk back to find the number
|
||||
j := i - 1
|
||||
for j >= 0 && (line[j] == '.' || (line[j] >= '0' && line[j] <= '9')) {
|
||||
j--
|
||||
}
|
||||
if j < i-1 {
|
||||
numStr := line[j+1 : i]
|
||||
// Validate it's a reasonable percentage
|
||||
if num, err := strconv.ParseFloat(numStr, 64); err == nil && num >= 0 && num <= 100 {
|
||||
return numStr + "%"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractCoverage extracts coverage percentage from task output
|
||||
// Supports common formats: "Coverage: 92%", "92% coverage", "coverage 92%", "TOTAL 92%"
|
||||
func extractCoverage(message string) string {
|
||||
if message == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return extractCoverageFromLines(strings.Split(message, "\n"))
|
||||
}
|
||||
|
||||
// extractCoverageNum extracts coverage as a numeric value for comparison
|
||||
func extractCoverageNum(coverage string) float64 {
|
||||
if coverage == "" {
|
||||
return 0
|
||||
}
|
||||
// Remove % sign and parse
|
||||
numStr := strings.TrimSuffix(coverage, "%")
|
||||
if num, err := strconv.ParseFloat(numStr, 64); err == nil {
|
||||
return num
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractFilesChangedFromLines extracts files from pre-split lines.
|
||||
func extractFilesChangedFromLines(lines []string) []string {
|
||||
if len(lines) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var files []string
|
||||
seen := make(map[string]bool)
|
||||
exts := []string{".ts", ".tsx", ".js", ".jsx", ".go", ".py", ".rs", ".java", ".vue", ".css", ".scss", ".md", ".json", ".yaml", ".yml", ".toml"}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Pattern 1: "Modified: path/to/file.ts" or "Created: path/to/file.ts"
|
||||
matchedPrefix := false
|
||||
for _, prefix := range []string{"Modified:", "Created:", "Updated:", "Edited:", "Wrote:", "Changed:"} {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
file := strings.TrimSpace(strings.TrimPrefix(line, prefix))
|
||||
file = strings.Trim(file, "`,\"'()[],:")
|
||||
file = strings.TrimPrefix(file, "@")
|
||||
if file != "" && !seen[file] {
|
||||
files = append(files, file)
|
||||
seen[file] = true
|
||||
}
|
||||
matchedPrefix = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if matchedPrefix {
|
||||
continue
|
||||
}
|
||||
|
||||
// Pattern 2: Tokens that look like file paths (allow root files, strip @ prefix).
|
||||
parts := strings.Fields(line)
|
||||
for _, part := range parts {
|
||||
part = strings.Trim(part, "`,\"'()[],:")
|
||||
part = strings.TrimPrefix(part, "@")
|
||||
for _, ext := range exts {
|
||||
if strings.HasSuffix(part, ext) && !seen[part] {
|
||||
files = append(files, part)
|
||||
seen[part] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit to first 10 files to avoid bloat
|
||||
if len(files) > 10 {
|
||||
files = files[:10]
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
// extractFilesChanged extracts list of changed files from task output
|
||||
// Looks for common patterns like "Modified: file.ts", "Created: file.ts", file paths in output
|
||||
func extractFilesChanged(message string) []string {
|
||||
if message == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return extractFilesChangedFromLines(strings.Split(message, "\n"))
|
||||
}
|
||||
|
||||
// extractTestResultsFromLines extracts test results from pre-split lines.
|
||||
func extractTestResultsFromLines(lines []string) (passed, failed int) {
|
||||
if len(lines) == 0 {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// Common patterns:
|
||||
// pytest: "12 passed, 2 failed"
|
||||
// jest: "Tests: 2 failed, 12 passed"
|
||||
// go: "ok ... 12 tests"
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.ToLower(line)
|
||||
|
||||
// Look for test result lines
|
||||
if !strings.Contains(line, "pass") && !strings.Contains(line, "fail") && !strings.Contains(line, "test") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract numbers near "passed" or "pass"
|
||||
if idx := strings.Index(line, "pass"); idx != -1 {
|
||||
// Look for number before "pass"
|
||||
num := extractNumberBefore(line, idx)
|
||||
if num > 0 {
|
||||
passed = num
|
||||
}
|
||||
}
|
||||
|
||||
// Extract numbers near "failed" or "fail"
|
||||
if idx := strings.Index(line, "fail"); idx != -1 {
|
||||
num := extractNumberBefore(line, idx)
|
||||
if num > 0 {
|
||||
failed = num
|
||||
}
|
||||
}
|
||||
|
||||
// go test style: "ok ... 12 tests"
|
||||
if passed == 0 {
|
||||
if idx := strings.Index(line, "test"); idx != -1 {
|
||||
num := extractNumberBefore(line, idx)
|
||||
if num > 0 {
|
||||
passed = num
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we found both, stop
|
||||
if passed > 0 && failed > 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return passed, failed
|
||||
}
|
||||
|
||||
// extractTestResults extracts test pass/fail counts from task output
|
||||
func extractTestResults(message string) (passed, failed int) {
|
||||
if message == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
return extractTestResultsFromLines(strings.Split(message, "\n"))
|
||||
}
|
||||
|
||||
// extractNumberBefore extracts a number that appears before the given index
|
||||
func extractNumberBefore(s string, idx int) int {
|
||||
if idx <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Walk backwards to find digits
|
||||
end := idx - 1
|
||||
for end >= 0 && (s[end] == ' ' || s[end] == ':' || s[end] == ',') {
|
||||
end--
|
||||
}
|
||||
if end < 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
start := end
|
||||
for start >= 0 && s[start] >= '0' && s[start] <= '9' {
|
||||
start--
|
||||
}
|
||||
start++
|
||||
|
||||
if start > end {
|
||||
return 0
|
||||
}
|
||||
|
||||
numStr := s[start : end+1]
|
||||
if num, err := strconv.Atoi(numStr); err == nil {
|
||||
return num
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// extractKeyOutputFromLines extracts key output from pre-split lines.
|
||||
func extractKeyOutputFromLines(lines []string, maxLen int) string {
|
||||
if len(lines) == 0 || maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Priority 1: Look for explicit summary lines
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
lower := strings.ToLower(line)
|
||||
if strings.HasPrefix(lower, "summary:") || strings.HasPrefix(lower, "completed:") ||
|
||||
strings.HasPrefix(lower, "implemented:") || strings.HasPrefix(lower, "added:") ||
|
||||
strings.HasPrefix(lower, "created:") || strings.HasPrefix(lower, "fixed:") {
|
||||
content := line
|
||||
for _, prefix := range []string{"Summary:", "Completed:", "Implemented:", "Added:", "Created:", "Fixed:",
|
||||
"summary:", "completed:", "implemented:", "added:", "created:", "fixed:"} {
|
||||
content = strings.TrimPrefix(content, prefix)
|
||||
}
|
||||
content = strings.TrimSpace(content)
|
||||
if len(content) > 0 {
|
||||
return safeTruncate(content, maxLen)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: First meaningful line (skip noise)
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "---") ||
|
||||
strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
// Skip very short lines (likely headers or markers)
|
||||
if len(line) < 20 {
|
||||
continue
|
||||
}
|
||||
return safeTruncate(line, maxLen)
|
||||
}
|
||||
|
||||
// Fallback: truncate entire message
|
||||
clean := strings.TrimSpace(strings.Join(lines, "\n"))
|
||||
return safeTruncate(clean, maxLen)
|
||||
}
|
||||
|
||||
// extractCoverageGap extracts what's missing from coverage reports
|
||||
// Looks for uncovered lines, branches, or functions
|
||||
func extractCoverageGap(message string) string {
|
||||
if message == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
lower := strings.ToLower(message)
|
||||
lines := strings.Split(message, "\n")
|
||||
|
||||
// Look for uncovered/missing patterns
|
||||
for _, line := range lines {
|
||||
lineLower := strings.ToLower(line)
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Common patterns for uncovered code
|
||||
if strings.Contains(lineLower, "uncovered") ||
|
||||
strings.Contains(lineLower, "not covered") ||
|
||||
strings.Contains(lineLower, "missing coverage") ||
|
||||
strings.Contains(lineLower, "lines not covered") {
|
||||
if len(line) > 100 {
|
||||
return line[:97] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// Look for specific file:line patterns in coverage reports
|
||||
if strings.Contains(lineLower, "branch") && strings.Contains(lineLower, "not taken") {
|
||||
if len(line) > 100 {
|
||||
return line[:97] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
|
||||
// Look for function names that aren't covered
|
||||
if strings.Contains(lower, "function") && strings.Contains(lower, "0%") {
|
||||
for _, line := range lines {
|
||||
if strings.Contains(strings.ToLower(line), "0%") && strings.Contains(line, "function") {
|
||||
line = strings.TrimSpace(line)
|
||||
if len(line) > 100 {
|
||||
return line[:97] + "..."
|
||||
}
|
||||
return line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// extractErrorDetail extracts meaningful error context from task output
|
||||
// Returns the most relevant error information up to maxLen characters
|
||||
func extractErrorDetail(message string, maxLen int) string {
|
||||
if message == "" || maxLen <= 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
lines := strings.Split(message, "\n")
|
||||
var errorLines []string
|
||||
|
||||
// Look for error-related lines
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
lower := strings.ToLower(line)
|
||||
|
||||
// Skip noise lines
|
||||
if strings.HasPrefix(line, "at ") && strings.Contains(line, "(") {
|
||||
// Stack trace line - only keep first one
|
||||
if len(errorLines) > 0 && strings.HasPrefix(strings.ToLower(errorLines[len(errorLines)-1]), "at ") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Prioritize error/fail lines
|
||||
if strings.Contains(lower, "error") ||
|
||||
strings.Contains(lower, "fail") ||
|
||||
strings.Contains(lower, "exception") ||
|
||||
strings.Contains(lower, "assert") ||
|
||||
strings.Contains(lower, "expected") ||
|
||||
strings.Contains(lower, "timeout") ||
|
||||
strings.Contains(lower, "not found") ||
|
||||
strings.Contains(lower, "cannot") ||
|
||||
strings.Contains(lower, "undefined") ||
|
||||
strings.HasPrefix(line, "FAIL") ||
|
||||
strings.HasPrefix(line, "●") {
|
||||
errorLines = append(errorLines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errorLines) == 0 {
|
||||
// No specific error lines found, take last few lines
|
||||
start := len(lines) - 5
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
for _, line := range lines[start:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
errorLines = append(errorLines, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Join and truncate
|
||||
result := strings.Join(errorLines, " | ")
|
||||
return safeTruncate(result, maxLen)
|
||||
}
|
||||
143
codeagent-wrapper/utils_test.go
Normal file
143
codeagent-wrapper/utils_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractCoverage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"bare int", "92%", "92%"},
|
||||
{"bare float", "92.5%", "92.5%"},
|
||||
{"coverage prefix", "coverage: 92%", "92%"},
|
||||
{"total prefix", "TOTAL 92%", "92%"},
|
||||
{"all files", "All files 92%", "92%"},
|
||||
{"empty", "", ""},
|
||||
{"no number", "coverage: N/A", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractCoverage(tt.in); got != tt.want {
|
||||
t.Fatalf("extractCoverage(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTestResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
wantPassed int
|
||||
wantFailed int
|
||||
}{
|
||||
{"pytest one line", "12 passed, 2 failed", 12, 2},
|
||||
{"pytest split lines", "12 passed\n2 failed", 12, 2},
|
||||
{"jest format", "Tests: 2 failed, 12 passed, 14 total", 12, 2},
|
||||
{"go test style count", "ok\texample.com/foo\t0.12s\t12 tests", 12, 0},
|
||||
{"zero counts", "0 passed, 0 failed", 0, 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
passed, failed := extractTestResults(tt.in)
|
||||
if passed != tt.wantPassed || failed != tt.wantFailed {
|
||||
t.Fatalf("extractTestResults(%q) = (%d, %d), want (%d, %d)", tt.in, passed, failed, tt.wantPassed, tt.wantFailed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFilesChanged(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"root file", "Modified: main.go\n", []string{"main.go"}},
|
||||
{"path file", "Created: codeagent-wrapper/utils.go\n", []string{"codeagent-wrapper/utils.go"}},
|
||||
{"at prefix", "Updated: @codeagent-wrapper/main.go\n", []string{"codeagent-wrapper/main.go"}},
|
||||
{"token scan", "Files: @main.go, @codeagent-wrapper/utils.go\n", []string{"main.go", "codeagent-wrapper/utils.go"}},
|
||||
{"space path", "Modified: dir/with space/file.go\n", []string{"dir/with space/file.go"}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := extractFilesChanged(tt.in); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("extractFilesChanged(%q) = %#v, want %#v", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("limits to first 10", func(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 0; i < 12; i++ {
|
||||
fmt.Fprintf(&b, "Modified: file%d.go\n", i)
|
||||
}
|
||||
got := extractFilesChanged(b.String())
|
||||
if len(got) != 10 {
|
||||
t.Fatalf("len(files)=%d, want 10: %#v", len(got), got)
|
||||
}
|
||||
for i := 0; i < 10; i++ {
|
||||
want := fmt.Sprintf("file%d.go", i)
|
||||
if got[i] != want {
|
||||
t.Fatalf("files[%d]=%q, want %q", i, got[i], want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSafeTruncate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
maxLen int
|
||||
want string
|
||||
}{
|
||||
{"empty", "", 4, ""},
|
||||
{"zero maxLen", "hello", 0, ""},
|
||||
{"one rune", "你好", 1, "你"},
|
||||
{"two runes no truncate", "你好", 2, "你好"},
|
||||
{"three runes no truncate", "你好", 3, "你好"},
|
||||
{"two runes truncates long", "你好世界", 2, "你"},
|
||||
{"three runes truncates long", "你好世界", 3, "你"},
|
||||
{"four with ellipsis", "你好世界啊", 4, "你..."},
|
||||
{"emoji", "🙂🙂🙂🙂🙂", 4, "🙂..."},
|
||||
{"no truncate", "你好世界", 4, "你好世界"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := safeTruncate(tt.in, tt.maxLen); got != tt.want {
|
||||
t.Fatalf("safeTruncate(%q, %d) = %q, want %q", tt.in, tt.maxLen, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"ansi", "\x1b[31mred\x1b[0m", "red"},
|
||||
{"control chars", "a\x07b\r\nc\t", "ab\nc\t"},
|
||||
{"normal", "hello\nworld\t!", "hello\nworld\t!"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeOutput(tt.in); got != tt.want {
|
||||
t.Fatalf("sanitizeOutput(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
126
codeagent-wrapper/wrapper_name.go
Normal file
126
codeagent-wrapper/wrapper_name.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWrapperName = "codeagent-wrapper"
|
||||
legacyWrapperName = "codex-wrapper"
|
||||
)
|
||||
|
||||
var executablePathFn = os.Executable
|
||||
|
||||
func normalizeWrapperName(path string) string {
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
base := filepath.Base(path)
|
||||
base = strings.TrimSuffix(base, ".exe") // tolerate Windows executables
|
||||
|
||||
switch base {
|
||||
case defaultWrapperName, legacyWrapperName:
|
||||
return base
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// currentWrapperName resolves the wrapper name based on the invoked binary.
|
||||
// Only known names are honored to avoid leaking build/test binary names into logs.
|
||||
func currentWrapperName() string {
|
||||
if len(os.Args) == 0 {
|
||||
return defaultWrapperName
|
||||
}
|
||||
|
||||
if name := normalizeWrapperName(os.Args[0]); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
execPath, err := executablePathFn()
|
||||
if err == nil {
|
||||
if name := normalizeWrapperName(execPath); name != "" {
|
||||
return name
|
||||
}
|
||||
|
||||
if resolved, err := filepath.EvalSymlinks(execPath); err == nil {
|
||||
if name := normalizeWrapperName(resolved); name != "" {
|
||||
return name
|
||||
}
|
||||
if alias := resolveAlias(execPath, resolved); alias != "" {
|
||||
return alias
|
||||
}
|
||||
}
|
||||
|
||||
if alias := resolveAlias(execPath, ""); alias != "" {
|
||||
return alias
|
||||
}
|
||||
}
|
||||
|
||||
return defaultWrapperName
|
||||
}
|
||||
|
||||
// logPrefixes returns the set of accepted log name prefixes, including the
|
||||
// current wrapper name and legacy aliases.
|
||||
func logPrefixes() []string {
|
||||
prefixes := []string{currentWrapperName(), defaultWrapperName, legacyWrapperName}
|
||||
seen := make(map[string]struct{}, len(prefixes))
|
||||
var unique []string
|
||||
for _, prefix := range prefixes {
|
||||
if prefix == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[prefix]; ok {
|
||||
continue
|
||||
}
|
||||
seen[prefix] = struct{}{}
|
||||
unique = append(unique, prefix)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
// primaryLogPrefix returns the preferred filename prefix for log files.
|
||||
// Defaults to the current wrapper name when available, otherwise falls back
|
||||
// to the canonical default name.
|
||||
func primaryLogPrefix() string {
|
||||
prefixes := logPrefixes()
|
||||
if len(prefixes) == 0 {
|
||||
return defaultWrapperName
|
||||
}
|
||||
return prefixes[0]
|
||||
}
|
||||
|
||||
func resolveAlias(execPath string, target string) string {
|
||||
if execPath == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
dir := filepath.Dir(execPath)
|
||||
for _, candidate := range []string{defaultWrapperName, legacyWrapperName} {
|
||||
aliasPath := filepath.Join(dir, candidate)
|
||||
info, err := os.Lstat(aliasPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if info.Mode()&os.ModeSymlink == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
resolved, err := filepath.EvalSymlinks(aliasPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if target != "" && resolved != target {
|
||||
continue
|
||||
}
|
||||
|
||||
if name := normalizeWrapperName(aliasPath); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
50
codeagent-wrapper/wrapper_name_test.go
Normal file
50
codeagent-wrapper/wrapper_name_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCurrentWrapperNameFallsBackToExecutable(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
execPath := filepath.Join(tempDir, "codeagent-wrapper")
|
||||
if err := os.WriteFile(execPath, []byte("#!/bin/true\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake binary: %v", err)
|
||||
}
|
||||
|
||||
os.Args = []string{filepath.Join(tempDir, "custom-name")}
|
||||
executablePathFn = func() (string, error) {
|
||||
return execPath, nil
|
||||
}
|
||||
|
||||
if got := currentWrapperName(); got != defaultWrapperName {
|
||||
t.Fatalf("currentWrapperName() = %q, want %q", got, defaultWrapperName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentWrapperNameDetectsLegacyAliasSymlink(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
execPath := filepath.Join(tempDir, "wrapper")
|
||||
aliasPath := filepath.Join(tempDir, legacyWrapperName)
|
||||
|
||||
if err := os.WriteFile(execPath, []byte("#!/bin/true\n"), 0o755); err != nil {
|
||||
t.Fatalf("failed to write fake binary: %v", err)
|
||||
}
|
||||
if err := os.Symlink(execPath, aliasPath); err != nil {
|
||||
t.Fatalf("failed to create alias: %v", err)
|
||||
}
|
||||
|
||||
os.Args = []string{filepath.Join(tempDir, "unknown-runner")}
|
||||
executablePathFn = func() (string, error) {
|
||||
return execPath, nil
|
||||
}
|
||||
|
||||
if got := currentWrapperName(); got != legacyWrapperName {
|
||||
t.Fatalf("currentWrapperName() = %q, want %q", got, legacyWrapperName)
|
||||
}
|
||||
}
|
||||
161
config.json
Normal file
161
config.json
Normal file
@@ -0,0 +1,161 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"install_dir": "~/.claude",
|
||||
"log_file": "install.log",
|
||||
"modules": {
|
||||
"dev": {
|
||||
"enabled": true,
|
||||
"description": "Core dev workflow with Codex integration",
|
||||
"operations": [
|
||||
{
|
||||
"type": "merge_dir",
|
||||
"source": "dev-workflow",
|
||||
"description": "Merge commands/ and agents/ into install dir"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "memorys/CLAUDE.md",
|
||||
"target": "CLAUDE.md",
|
||||
"description": "Copy core role and guidelines"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/codeagent/SKILL.md",
|
||||
"target": "skills/codeagent/SKILL.md",
|
||||
"description": "Install codeagent skill"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/product-requirements/SKILL.md",
|
||||
"target": "skills/product-requirements/SKILL.md",
|
||||
"description": "Install product-requirements skill"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/prototype-prompt-generator/SKILL.md",
|
||||
"target": "skills/prototype-prompt-generator/SKILL.md",
|
||||
"description": "Install prototype-prompt-generator skill"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/prototype-prompt-generator/references/prompt-structure.md",
|
||||
"target": "skills/prototype-prompt-generator/references/prompt-structure.md",
|
||||
"description": "Install prototype-prompt-generator prompt structure reference"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/prototype-prompt-generator/references/design-systems.md",
|
||||
"target": "skills/prototype-prompt-generator/references/design-systems.md",
|
||||
"description": "Install prototype-prompt-generator design systems reference"
|
||||
},
|
||||
{
|
||||
"type": "run_command",
|
||||
"command": "bash install.sh",
|
||||
"description": "Install codeagent-wrapper binary",
|
||||
"env": {
|
||||
"INSTALL_DIR": "${install_dir}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"bmad": {
|
||||
"enabled": false,
|
||||
"description": "BMAD agile workflow with multi-agent orchestration",
|
||||
"operations": [
|
||||
{
|
||||
"type": "merge_dir",
|
||||
"source": "bmad-agile-workflow",
|
||||
"description": "Merge BMAD commands and agents"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "docs/BMAD-WORKFLOW.md",
|
||||
"target": "docs/BMAD-WORKFLOW.md",
|
||||
"description": "Copy BMAD workflow documentation"
|
||||
}
|
||||
]
|
||||
},
|
||||
"requirements": {
|
||||
"enabled": false,
|
||||
"description": "Requirements-driven development workflow",
|
||||
"operations": [
|
||||
{
|
||||
"type": "merge_dir",
|
||||
"source": "requirements-driven-workflow",
|
||||
"description": "Merge requirements workflow commands and agents"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "docs/REQUIREMENTS-WORKFLOW.md",
|
||||
"target": "docs/REQUIREMENTS-WORKFLOW.md",
|
||||
"description": "Copy requirements workflow documentation"
|
||||
}
|
||||
]
|
||||
},
|
||||
"essentials": {
|
||||
"enabled": true,
|
||||
"description": "Core development commands and utilities",
|
||||
"operations": [
|
||||
{
|
||||
"type": "merge_dir",
|
||||
"source": "development-essentials",
|
||||
"description": "Merge essential development commands"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "docs/DEVELOPMENT-COMMANDS.md",
|
||||
"target": "docs/DEVELOPMENT-COMMANDS.md",
|
||||
"description": "Copy development commands documentation"
|
||||
}
|
||||
]
|
||||
},
|
||||
"omo": {
|
||||
"enabled": false,
|
||||
"description": "OmO multi-agent orchestration with Sisyphus coordinator",
|
||||
"operations": [
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/SKILL.md",
|
||||
"target": "skills/omo/SKILL.md",
|
||||
"description": "Install omo skill"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/references/oracle.md",
|
||||
"target": "skills/omo/references/oracle.md",
|
||||
"description": "Install oracle agent prompt"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/references/librarian.md",
|
||||
"target": "skills/omo/references/librarian.md",
|
||||
"description": "Install librarian agent prompt"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/references/explore.md",
|
||||
"target": "skills/omo/references/explore.md",
|
||||
"description": "Install explore agent prompt"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/references/frontend-ui-ux-engineer.md",
|
||||
"target": "skills/omo/references/frontend-ui-ux-engineer.md",
|
||||
"description": "Install frontend-ui-ux-engineer agent prompt"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/references/document-writer.md",
|
||||
"target": "skills/omo/references/document-writer.md",
|
||||
"description": "Install document-writer agent prompt"
|
||||
},
|
||||
{
|
||||
"type": "copy_file",
|
||||
"source": "skills/omo/references/develop.md",
|
||||
"target": "skills/omo/references/develop.md",
|
||||
"description": "Install develop agent prompt"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
122
config.schema.json
Normal file
122
config.schema.json
Normal file
@@ -0,0 +1,122 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://github.com/cexll/myclaude/config.schema.json",
|
||||
"title": "Modular Installation Config",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["version", "install_dir", "log_file", "modules"],
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "string",
|
||||
"pattern": "^[0-9]+\\.[0-9]+(\\.[0-9]+)?$"
|
||||
},
|
||||
"install_dir": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "Target installation directory, supports ~/ expansion"
|
||||
},
|
||||
"log_file": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"modules": {
|
||||
"type": "object",
|
||||
"description": "可自定义的模块定义,每个模块名称可任意指定",
|
||||
"patternProperties": {
|
||||
"^[a-zA-Z0-9_-]+$": { "$ref": "#/$defs/module" }
|
||||
},
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"module": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "description", "operations"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean", "default": false },
|
||||
"description": { "type": "string", "minLength": 3 },
|
||||
"operations": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "$ref": "#/$defs/operation" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"operation": {
|
||||
"oneOf": [
|
||||
{ "$ref": "#/$defs/op_copy_dir" },
|
||||
{ "$ref": "#/$defs/op_copy_file" },
|
||||
{ "$ref": "#/$defs/op_merge_dir" },
|
||||
{ "$ref": "#/$defs/op_merge_json" },
|
||||
{ "$ref": "#/$defs/op_run_command" }
|
||||
]
|
||||
},
|
||||
"common_operation_fields": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"description": { "type": "string" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"op_copy_dir": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "source", "target"],
|
||||
"properties": {
|
||||
"type": { "const": "copy_dir" },
|
||||
"source": { "type": "string", "minLength": 1 },
|
||||
"target": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"op_copy_file": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "source", "target"],
|
||||
"properties": {
|
||||
"type": { "const": "copy_file" },
|
||||
"source": { "type": "string", "minLength": 1 },
|
||||
"target": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"op_merge_dir": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "source"],
|
||||
"properties": {
|
||||
"type": { "const": "merge_dir" },
|
||||
"source": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"op_merge_json": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "source", "target"],
|
||||
"properties": {
|
||||
"type": { "const": "merge_json" },
|
||||
"source": { "type": "string", "minLength": 1 },
|
||||
"target": { "type": "string", "minLength": 1 },
|
||||
"merge_key": { "type": "string" },
|
||||
"description": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"op_run_command": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "command"],
|
||||
"properties": {
|
||||
"type": { "const": "run_command" },
|
||||
"command": { "type": "string", "minLength": 1 },
|
||||
"description": { "type": "string" },
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
9
dev-workflow/.claude-plugin/plugin.json
Normal file
9
dev-workflow/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "dev",
|
||||
"description": "Lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,192 @@
|
||||
# /dev - 极简开发工作流
|
||||
# /dev - Minimal Dev Workflow
|
||||
|
||||
## 概述
|
||||
## Overview
|
||||
|
||||
全新设计的轻量级开发工作流,无历史包袱,专注快速交付高质量代码。
|
||||
A freshly designed lightweight development workflow with no legacy baggage, focused on delivering high-quality code fast.
|
||||
|
||||
## 工作流程
|
||||
## Flow
|
||||
|
||||
```
|
||||
/dev 触发
|
||||
/dev trigger
|
||||
↓
|
||||
AskUserQuestion(需求澄清)
|
||||
AskUserQuestion (backend selection)
|
||||
↓
|
||||
Codex 分析(提取要点和任务)
|
||||
AskUserQuestion (requirements clarification)
|
||||
↓
|
||||
develop-doc-generator(生成开发文档)
|
||||
codeagent analysis (plan mode + task typing + UI auto-detection)
|
||||
↓
|
||||
Codex 并发开发(2-5个任务)
|
||||
dev-plan-generator (create dev doc)
|
||||
↓
|
||||
Codex 测试验证(≥90%覆盖率)
|
||||
codeagent concurrent development (2–5 tasks, backend routing)
|
||||
↓
|
||||
完成(生成总结)
|
||||
codeagent testing & verification (≥90% coverage)
|
||||
↓
|
||||
Done (generate summary)
|
||||
```
|
||||
|
||||
## 6个步骤
|
||||
## Step 0 + The 6 Steps
|
||||
|
||||
### 1. 需求澄清
|
||||
- 使用 **AskUserQuestion** 直接问用户
|
||||
- 无评分系统,无复杂逻辑
|
||||
- 2-3 轮问答直到需求明确
|
||||
### 0. Select Allowed Backends (FIRST ACTION)
|
||||
- Use **AskUserQuestion** with multiSelect to ask which backends are allowed for this run
|
||||
- Options (user can select multiple):
|
||||
- `codex` - Stable, high quality, best cost-performance (default for most tasks)
|
||||
- `claude` - Fast, lightweight (for quick fixes and config changes)
|
||||
- `gemini` - UI/UX specialist (for frontend styling and components)
|
||||
- If user selects ONLY `codex`, ALL subsequent tasks must use `codex` (including UI/quick-fix)
|
||||
|
||||
### 2. Codex 分析
|
||||
- 调用 codex 分析需求
|
||||
- 提取:核心功能、技术要点、任务列表(2-5个)
|
||||
- 输出结构化分析结果
|
||||
### 1. Clarify Requirements
|
||||
- Use **AskUserQuestion** to ask the user directly
|
||||
- No scoring system, no complex logic
|
||||
- 2–3 rounds of Q&A until the requirement is clear
|
||||
|
||||
### 3. 生成开发文档
|
||||
- 调用 **develop-doc-generator** agent
|
||||
- 生成 `dev-plan.md`(单一开发文档)
|
||||
- 包含:任务分解、文件范围、依赖关系、测试命令
|
||||
### 2. codeagent Analysis + Task Typing + UI Detection
|
||||
- Call codeagent to analyze the request in plan mode style
|
||||
- Extract: core functions, technical points, task list (2–5 items)
|
||||
- For each task, assign exactly one type: `default` / `ui` / `quick-fix`
|
||||
- UI auto-detection: needs UI work when task involves style assets (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component files (.tsx, .jsx, .vue); output yes/no plus evidence
|
||||
|
||||
### 4. 并发开发
|
||||
- 基于 dev-plan.md 的任务列表
|
||||
- 无依赖任务 → 并发执行
|
||||
- 有冲突任务 → 串行执行
|
||||
### 3. Generate Dev Doc
|
||||
- Call the **dev-plan-generator** agent
|
||||
- Produce a single `dev-plan.md`
|
||||
- Append a dedicated UI task when Step 2 marks `needs_ui: true`
|
||||
- Include: task breakdown, `type`, file scope, dependencies, test commands
|
||||
|
||||
### 5. 测试验证
|
||||
- 每个 codex 任务自己:
|
||||
- 实现功能
|
||||
- 编写测试
|
||||
- 运行覆盖率
|
||||
- 报告结果(≥90%)
|
||||
### 4. Concurrent Development
|
||||
- Work from the task list in dev-plan.md
|
||||
- Route backend per task type (with user constraints + fallback):
|
||||
- `default` → `codex`
|
||||
- `ui` → `gemini` (enforced when allowed)
|
||||
- `quick-fix` → `claude`
|
||||
- Missing `type` → treat as `default`
|
||||
- If the preferred backend is not allowed, fallback to an allowed backend by priority: `codex` → `claude` → `gemini`
|
||||
- Independent tasks → run in parallel
|
||||
- Conflicting tasks → run serially
|
||||
|
||||
### 6. 完成
|
||||
- 汇总任务状态
|
||||
- 记录覆盖率
|
||||
### 5. Testing & Verification
|
||||
- Each codeagent task:
|
||||
- Implements the feature
|
||||
- Writes tests
|
||||
- Runs coverage
|
||||
- Reports results (≥90%)
|
||||
|
||||
## 使用方法
|
||||
### 6. Complete
|
||||
- Summarize task status
|
||||
- Record coverage
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
/dev "实现用户登录功能,支持邮箱和密码验证"
|
||||
/dev "Implement user login with email + password"
|
||||
```
|
||||
|
||||
**无选项**,流程固定,开箱即用。
|
||||
No CLI flags required; workflow starts with an interactive backend selection.
|
||||
|
||||
## 输出结构
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
.claude/specs/{feature_name}/
|
||||
├── dev-plan.md # 开发文档(agent生成)
|
||||
└── dev-plan.md # Dev document generated by agent
|
||||
```
|
||||
|
||||
仅 2 个文件,极简清晰。
|
||||
Only one file—minimal and clear.
|
||||
|
||||
## 核心组件
|
||||
## Core Components
|
||||
|
||||
### 工具
|
||||
- **AskUserQuestion**:交互式需求澄清
|
||||
- **codex**:分析、开发、测试
|
||||
- **develop-doc-generator**:生成开发文档(subagent,节省上下文)
|
||||
### Tools
|
||||
- **AskUserQuestion**: interactive requirement clarification
|
||||
- **codeagent skill**: analysis, development, testing; supports `--backend` for `codex` / `claude` / `gemini`
|
||||
- **dev-plan-generator agent**: generate dev doc (subagent via Task tool, saves context)
|
||||
|
||||
## 核心特性
|
||||
## Backend Selection & Routing
|
||||
- **Step 0**: user selects allowed backends; if `仅 codex`, all tasks use codex
|
||||
- **UI detection standard**: style files (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component code (.tsx, .jsx, .vue) trigger `needs_ui: true`
|
||||
- **Task type field**: each task in `dev-plan.md` must have `type: default|ui|quick-fix`
|
||||
- **Routing**: `default`→codex, `ui`→gemini, `quick-fix`→claude; if disallowed, fallback to an allowed backend by priority: codex→claude→gemini
|
||||
|
||||
### ✅ 全新设计
|
||||
- 无历史项目残留
|
||||
- 无复杂评分逻辑
|
||||
- 无多余抽象层
|
||||
## Key Features
|
||||
|
||||
### ✅ 极简编排
|
||||
- orchestrator 直接控制流程
|
||||
- 只用 3 个工具/组件
|
||||
- 步骤清晰易懂
|
||||
### ✅ Fresh Design
|
||||
- No legacy project residue
|
||||
- No complex scoring logic
|
||||
- No extra abstraction layers
|
||||
|
||||
### ✅ 并发能力
|
||||
- 2-5 个任务并行
|
||||
- 自动检测依赖和冲突
|
||||
- codex 独立执行
|
||||
### ✅ Minimal Orchestration
|
||||
- Orchestrator controls the flow directly
|
||||
- Only three tools/components
|
||||
- Steps are straightforward
|
||||
|
||||
### ✅ 质量保证
|
||||
- 强制 90% 覆盖率
|
||||
- codex 自己测试和验证
|
||||
- 失败自动重试
|
||||
### ✅ Concurrency
|
||||
- Tasks split based on natural functional boundaries
|
||||
- Auto-detect dependencies and conflicts
|
||||
- codeagent executes independently with optimal backend
|
||||
|
||||
## 示例
|
||||
### ✅ Quality Assurance
|
||||
- Enforces 90% coverage
|
||||
- codeagent tests and verifies its own work
|
||||
- Automatic retry on failure
|
||||
|
||||
## Example
|
||||
|
||||
```bash
|
||||
# 触发
|
||||
/dev "添加用户登录功能"
|
||||
# Trigger
|
||||
/dev "Add user login feature"
|
||||
|
||||
# 步骤 1: 需求澄清
|
||||
Q: 支持哪些登录方式?
|
||||
A: 邮箱 + 密码
|
||||
Q: 需要记住登录状态吗?
|
||||
A: 是,使用 JWT token
|
||||
# Step 0: Select backends
|
||||
Q: Which backends are allowed? (multiSelect)
|
||||
A: Selected: codex, claude
|
||||
|
||||
# 步骤 2: Codex 分析
|
||||
输出:
|
||||
- 核心功能:邮箱密码登录 + JWT认证
|
||||
- 任务 1:后端 API
|
||||
- 任务 2:密码加密
|
||||
- 任务 3:前端表单
|
||||
# Step 1: Clarify requirements
|
||||
Q: What login methods are supported?
|
||||
A: Email + password
|
||||
Q: Should login be remembered?
|
||||
A: Yes, use JWT token
|
||||
|
||||
# 步骤 3: 生成文档
|
||||
dev-plan.md 已生成 ✓
|
||||
# Step 2: codeagent analysis
|
||||
Output:
|
||||
- Core: email/password login + JWT auth
|
||||
- Task 1: Backend API (type=default)
|
||||
- Task 2: Password hashing (type=default)
|
||||
- Task 3: Frontend form (type=ui)
|
||||
UI detection: needs_ui = true (tailwindcss classes in frontend form)
|
||||
|
||||
# 步骤 4-5: 并发开发
|
||||
[task-1] 后端API → 测试 → 92% ✓
|
||||
[task-2] 密码加密 → 测试 → 95% ✓
|
||||
[task-3] 前端表单 → 测试 → 91% ✓
|
||||
# Step 3: Generate doc
|
||||
dev-plan.md generated with typed tasks ✓
|
||||
|
||||
# Step 4-5: Concurrent development (routing + fallback)
|
||||
[task-1] Backend API (codex) → tests → 92% ✓
|
||||
[task-2] Password hashing (codex) → tests → 95% ✓
|
||||
[task-3] Frontend form (fallback to codex; gemini not allowed) → tests → 91% ✓
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
dev-workflow/
|
||||
├── README.md # 本文档
|
||||
├── README.md # This doc
|
||||
├── commands/
|
||||
│ └── dev.md # 工作流定义
|
||||
│ └── dev.md # /dev workflow orchestrator definition
|
||||
└── agents/
|
||||
└── develop-doc-generator.md # 文档生成器
|
||||
└── dev-plan-generator.md # Dev plan document generator agent
|
||||
```
|
||||
|
||||
极简结构,只有 3 个文件。
|
||||
Minimal structure, only three files.
|
||||
|
||||
## 适用场景
|
||||
## When to Use
|
||||
|
||||
✅ **适合**:
|
||||
- 任何规模的功能开发
|
||||
- 需要快速迭代
|
||||
- 需要高测试覆盖率
|
||||
- 希望并发提速
|
||||
✅ **Good for**:
|
||||
- Any feature size
|
||||
- Fast iterations
|
||||
- High test coverage needs
|
||||
- Wanting concurrent speed-up
|
||||
|
||||
## 设计原则
|
||||
## Design Principles
|
||||
|
||||
1. **KISS**:保持简单愚蠢
|
||||
2. **即用即抛**:无持久化配置
|
||||
3. **质量优先**:强制 90% 覆盖率
|
||||
4. **并发优先**:充分利用 codex 能力
|
||||
5. **无历史包袱**:全新设计,不受其他项目影响
|
||||
1. **KISS**: keep it simple
|
||||
2. **Disposable**: no persistent config
|
||||
3. **Quality first**: enforce 90% coverage
|
||||
4. **Concurrency first**: leverage codeagent
|
||||
5. **No legacy baggage**: clean-slate design
|
||||
|
||||
---
|
||||
|
||||
**哲学**:像 Linus 一样对复杂度零容忍,交付能立刻用的最小方案。
|
||||
**Philosophy**: zero tolerance for complexity—ship the smallest usable solution, like Linus would.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dev-plan-generator
|
||||
description: Use this agent when you need to generate a structured development plan document (`dev-plan.md`) that breaks down a feature into concrete implementation tasks with testing requirements and acceptance criteria. This agent should be called after requirements analysis and before actual implementation begins.\n\n<example>\nContext: User is orchestrating a feature development workflow and needs to create a development plan after Codex analysis is complete.\nuser: "Create a development plan for the user authentication feature based on the requirements and analysis"\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to create the structured development plan document."\n<commentary>\nThe user needs a dev-plan.md document generated from requirements and analysis. Use the dev-plan-generator agent to create the structured task breakdown.\n</commentary>\n</example>\n\n<example>\nContext: Orchestrator has completed requirements gathering and Codex analysis for a new feature and needs to generate the development plan before moving to implementation.\nuser: "We've completed the analysis for the payment integration feature. Generate the development plan."\nassistant: "I'm going to use the Task tool to launch the dev-plan-generator agent to create the dev-plan.md document with task breakdown and testing requirements."\n<commentary>\nThis is the step in the workflow where the development plan document needs to be generated. Use the dev-plan-generator agent to create the structured plan.\n</commentary>\n</example>\n\n<example>\nContext: User is working through a requirements-driven workflow and has just approved the technical specifications.\nuser: "The specs look good. Let's move forward with creating the implementation plan."\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to generate the dev-plan.md document with the task breakdown."\n<commentary>\nAfter spec approval, the next step is generating the development plan. Use the dev-plan-generator agent to create the structured document.\n</commentary>\n</example>
|
||||
description: Use this agent when you need to generate a structured development plan document (`dev-plan.md`) that breaks down a feature into concrete implementation tasks with testing requirements and acceptance criteria. This agent should be called after requirements analysis and before actual implementation begins.\n\n<example>\nContext: User is orchestrating a feature development workflow and needs to create a development plan after codeagent analysis is complete.\nuser: "Create a development plan for the user authentication feature based on the requirements and analysis"\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to create the structured development plan document."\n<commentary>\nThe user needs a dev-plan.md document generated from requirements and analysis. Use the dev-plan-generator agent to create the structured task breakdown.\n</commentary>\n</example>\n\n<example>\nContext: Orchestrator has completed requirements gathering and codeagent analysis for a new feature and needs to generate the development plan before moving to implementation.\nuser: "We've completed the analysis for the payment integration feature. Generate the development plan."\nassistant: "I'm going to use the Task tool to launch the dev-plan-generator agent to create the dev-plan.md document with task breakdown and testing requirements."\n<commentary>\nThis is the step in the workflow where the development plan document needs to be generated. Use the dev-plan-generator agent to create the structured plan.\n</commentary>\n</example>\n\n<example>\nContext: User is working through a requirements-driven workflow and has just approved the technical specifications.\nuser: "The specs look good. Let's move forward with creating the implementation plan."\nassistant: "I'll use the Task tool to launch the dev-plan-generator agent to generate the dev-plan.md document with the task breakdown."\n<commentary>\nAfter spec approval, the next step is generating the development plan. Use the dev-plan-generator agent to create the structured document.\n</commentary>\n</example>
|
||||
tools: Glob, Grep, Read, Edit, Write, TodoWrite
|
||||
model: sonnet
|
||||
color: green
|
||||
@@ -12,7 +12,7 @@ You are a specialized Development Plan Document Generator. Your sole responsibil
|
||||
|
||||
You receive context from an orchestrator including:
|
||||
- Feature requirements description
|
||||
- Codex analysis results (feature highlights, task decomposition)
|
||||
- codeagent analysis results (feature highlights, task decomposition, UI detection flag, and task typing hints)
|
||||
- Feature name (in kebab-case format)
|
||||
|
||||
Your output is a single file: `./.claude/specs/{feature_name}/dev-plan.md`
|
||||
@@ -20,45 +20,50 @@ Your output is a single file: `./.claude/specs/{feature_name}/dev-plan.md`
|
||||
## Document Structure You Must Follow
|
||||
|
||||
```markdown
|
||||
# {Feature Name} - 开发计划
|
||||
# {Feature Name} - Development Plan
|
||||
|
||||
## 功能概述
|
||||
[一句话描述核心功能]
|
||||
## Overview
|
||||
[One-sentence description of core functionality]
|
||||
|
||||
## 任务分解
|
||||
## Task Breakdown
|
||||
|
||||
### 任务 1: [任务名称]
|
||||
### Task 1: [Task Name]
|
||||
- **ID**: task-1
|
||||
- **描述**: [具体要做什么]
|
||||
- **文件范围**: [涉及的目录或文件,如 src/auth/**, tests/auth/]
|
||||
- **依赖**: [无 或 依赖 task-x]
|
||||
- **测试命令**: [如 pytest tests/auth --cov=src/auth --cov-report=term]
|
||||
- **测试重点**: [需要覆盖的场景]
|
||||
- **type**: default|ui|quick-fix
|
||||
- **Description**: [What needs to be done]
|
||||
- **File Scope**: [Directories or files involved, e.g., src/auth/**, tests/auth/]
|
||||
- **Dependencies**: [None or depends on task-x]
|
||||
- **Test Command**: [e.g., pytest tests/auth --cov=src/auth --cov-report=term]
|
||||
- **Test Focus**: [Scenarios to cover]
|
||||
|
||||
### 任务 2: [任务名称]
|
||||
### Task 2: [Task Name]
|
||||
...
|
||||
|
||||
(2-5个任务)
|
||||
(Tasks based on natural functional boundaries, typically 2-5)
|
||||
|
||||
## 验收标准
|
||||
- [ ] 功能点 1
|
||||
- [ ] 功能点 2
|
||||
- [ ] 所有单元测试通过
|
||||
- [ ] 代码覆盖率 ≥90%
|
||||
## Acceptance Criteria
|
||||
- [ ] Feature point 1
|
||||
- [ ] Feature point 2
|
||||
- [ ] All unit tests pass
|
||||
- [ ] Code coverage ≥90%
|
||||
|
||||
## 技术要点
|
||||
- [关键技术决策]
|
||||
- [需要注意的约束]
|
||||
## Technical Notes
|
||||
- [Key technical decisions]
|
||||
- [Constraints to be aware of]
|
||||
```
|
||||
|
||||
## Generation Rules You Must Enforce
|
||||
|
||||
1. **Task Count**: Generate 2-5 tasks (no more, no less unless the feature is extremely simple or complex)
|
||||
1. **Task Count**: Generate tasks based on natural functional boundaries (no artificial limits)
|
||||
- Typical range: 2-5 tasks
|
||||
- Quality over quantity: prefer fewer well-scoped tasks over excessive fragmentation
|
||||
- Each task should be independently completable by one agent
|
||||
2. **Task Requirements**: Each task MUST include:
|
||||
- Clear ID (task-1, task-2, etc.)
|
||||
- A single task type field: `type: default|ui|quick-fix`
|
||||
- Specific description of what needs to be done
|
||||
- Explicit file scope (directories or files affected)
|
||||
- Dependency declaration ("无" or "依赖 task-x")
|
||||
- Dependency declaration ("None" or "depends on task-x")
|
||||
- Complete test command with coverage parameters
|
||||
- Testing focus points (scenarios to cover)
|
||||
3. **Task Independence**: Design tasks to be as independent as possible to enable parallel execution
|
||||
@@ -67,18 +72,23 @@ Your output is a single file: `./.claude/specs/{feature_name}/dev-plan.md`
|
||||
|
||||
## Your Workflow
|
||||
|
||||
1. **Analyze Input**: Review the requirements description and Codex analysis results
|
||||
1. **Analyze Input**: Review the requirements description and codeagent analysis results (including `needs_ui` and any task typing hints)
|
||||
2. **Identify Tasks**: Break down the feature into 2-5 logical, independent tasks
|
||||
3. **Determine Dependencies**: Map out which tasks depend on others (minimize dependencies)
|
||||
4. **Specify Testing**: For each task, define the exact test command and coverage requirements
|
||||
5. **Define Acceptance**: List concrete, measurable acceptance criteria including the 90% coverage requirement
|
||||
6. **Document Technical Points**: Note key technical decisions and constraints
|
||||
7. **Write File**: Use the Write tool to create `./.claude/specs/{feature_name}/dev-plan.md`
|
||||
4. **Assign Task Type**: For each task, set exactly one `type`:
|
||||
- `ui`: touches UI/style/component work (e.g., .css/.scss/.tsx/.jsx/.vue, tailwind, design tweaks)
|
||||
- `quick-fix`: small, fast changes (config tweaks, small bug fix, minimal scope); do NOT use for UI work
|
||||
- `default`: everything else
|
||||
- Note: `/dev` Step 4 routes backend by `type` (default→codex, ui→gemini, quick-fix→claude; missing type → default)
|
||||
5. **Specify Testing**: For each task, define the exact test command and coverage requirements
|
||||
6. **Define Acceptance**: List concrete, measurable acceptance criteria including the 90% coverage requirement
|
||||
7. **Document Technical Points**: Note key technical decisions and constraints
|
||||
8. **Write File**: Use the Write tool to create `./.claude/specs/{feature_name}/dev-plan.md`
|
||||
|
||||
## Quality Checks Before Writing
|
||||
|
||||
- [ ] Task count is between 2-5
|
||||
- [ ] Every task has all 6 required fields (ID, 描述, 文件范围, 依赖, 测试命令, 测试重点)
|
||||
- [ ] Every task has all required fields (ID, type, Description, File Scope, Dependencies, Test Command, Test Focus)
|
||||
- [ ] Test commands include coverage parameters
|
||||
- [ ] Dependencies are explicitly stated
|
||||
- [ ] Acceptance criteria includes 90% coverage requirement
|
||||
@@ -90,7 +100,7 @@ Your output is a single file: `./.claude/specs/{feature_name}/dev-plan.md`
|
||||
- **Document Only**: You generate documentation. You do NOT execute code, run tests, or modify source files.
|
||||
- **Single Output**: You produce exactly one file: `dev-plan.md` in the correct location
|
||||
- **Path Accuracy**: The path must be `./.claude/specs/{feature_name}/dev-plan.md` where {feature_name} matches the input
|
||||
- **Chinese Language**: The document must be in Chinese (as shown in the structure)
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc)
|
||||
- **Structured Format**: Follow the exact markdown structure provided
|
||||
|
||||
## Example Output Quality
|
||||
|
||||
@@ -1,82 +1,184 @@
|
||||
---
|
||||
description: Extreme lightweight end-to-end development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage
|
||||
description: Extreme lightweight end-to-end development workflow with requirements clarification, intelligent backend selection, parallel codeagent execution, and mandatory 90% test coverage
|
||||
---
|
||||
|
||||
|
||||
You are the /dev Workflow Orchestrator, an expert development workflow manager specializing in orchestrating minimal, efficient end-to-end development processes with parallel task execution and rigorous test coverage validation.
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL CONSTRAINTS (NEVER VIOLATE)
|
||||
|
||||
These rules have HIGHEST PRIORITY and override all other instructions:
|
||||
|
||||
1. **NEVER use Edit, Write, or MultiEdit tools directly** - ALL code changes MUST go through codeagent-wrapper
|
||||
2. **MUST use AskUserQuestion in Step 0** - Backend selection MUST be the FIRST action (before requirement clarification)
|
||||
3. **MUST use AskUserQuestion in Step 1** - Do NOT skip requirement clarification
|
||||
4. **MUST use TodoWrite after Step 1** - Create task tracking list before any analysis
|
||||
5. **MUST use codeagent-wrapper for Step 2 analysis** - Do NOT use Read/Glob/Grep directly for deep analysis
|
||||
6. **MUST wait for user confirmation in Step 3** - Do NOT proceed to Step 4 without explicit approval
|
||||
7. **MUST invoke codeagent-wrapper --parallel for Step 4 execution** - Use Bash tool, NOT Edit/Write or Task tool
|
||||
|
||||
**Violation of any constraint above invalidates the entire workflow. Stop and restart if violated.**
|
||||
|
||||
---
|
||||
|
||||
**Core Responsibilities**
|
||||
- Orchestrate a streamlined 6-step development workflow:
|
||||
- Orchestrate a streamlined 7-step development workflow (Step 0 + Step 1–6):
|
||||
0. Backend selection (user constrained)
|
||||
1. Requirement clarification through targeted questioning
|
||||
2. Technical analysis using Codex
|
||||
2. Technical analysis using codeagent-wrapper
|
||||
3. Development documentation generation
|
||||
4. Parallel development execution
|
||||
4. Parallel development execution (backend routing per task type)
|
||||
5. Coverage validation (≥90% requirement)
|
||||
6. Completion summary
|
||||
|
||||
**Workflow Execution**
|
||||
- **Step 1: Requirement Clarification**
|
||||
- Use AskUserQuestion to clarify requirements directly
|
||||
- Focus questions on functional boundaries, inputs/outputs, constraints, testing
|
||||
- **Step 0: Backend Selection [MANDATORY - FIRST ACTION]**
|
||||
- MUST use AskUserQuestion tool as the FIRST action with multiSelect enabled
|
||||
- Ask which backends are allowed for this /dev run
|
||||
- Options (user can select multiple):
|
||||
- `codex` - Stable, high quality, best cost-performance (default for most tasks)
|
||||
- `claude` - Fast, lightweight (for quick fixes and config changes)
|
||||
- `gemini` - UI/UX specialist (for frontend styling and components)
|
||||
- Store the selected backends as `allowed_backends` set for routing in Step 4
|
||||
- Special rule: if user selects ONLY `codex`, then ALL subsequent tasks (including UI/quick-fix) MUST use `codex` (no exceptions)
|
||||
|
||||
- **Step 1: Requirement Clarification [MANDATORY - DO NOT SKIP]**
|
||||
- MUST use AskUserQuestion tool
|
||||
- Focus questions on functional boundaries, inputs/outputs, constraints, testing, and required unit-test coverage levels
|
||||
- Iterate 2-3 rounds until clear; rely on judgment; keep questions concise
|
||||
- After clarification complete: MUST use TodoWrite to create task tracking list with workflow steps
|
||||
|
||||
- **Step 2: Codex Analysis**
|
||||
- Run:
|
||||
```bash
|
||||
uv run ~/.claude/skills/codex/scripts/codex.py "分析以下需求并提取开发要点:
|
||||
- **Step 2: codeagent-wrapper Deep Analysis (Plan Mode Style) [USE CODEAGENT-WRAPPER ONLY]**
|
||||
|
||||
需求描述:
|
||||
[用户需求 + 澄清后的细节]
|
||||
MUST use Bash tool to invoke `codeagent-wrapper` for deep analysis. Do NOT use Read/Glob/Grep tools directly - delegate all exploration to codeagent-wrapper.
|
||||
|
||||
请输出:
|
||||
1. 核心功能(一句话)
|
||||
2. 关键技术点
|
||||
3. 可并发的任务分解(2-5个):
|
||||
- 任务ID
|
||||
- 任务描述
|
||||
- 涉及文件/目录
|
||||
- 是否依赖其他任务
|
||||
- 测试重点
|
||||
" "gpt-5.1-codex"
|
||||
```
|
||||
- Extract core functionality, technical key points, and 2-5 parallelizable tasks with full metadata
|
||||
**How to invoke for analysis**:
|
||||
```bash
|
||||
# analysis_backend selection:
|
||||
# - prefer codex if it is in allowed_backends
|
||||
# - otherwise pick the first backend in allowed_backends
|
||||
codeagent-wrapper --backend {analysis_backend} - <<'EOF'
|
||||
Analyze the codebase for implementing [feature name].
|
||||
|
||||
Requirements:
|
||||
- [requirement 1]
|
||||
- [requirement 2]
|
||||
|
||||
Deliverables:
|
||||
1. Explore codebase structure and existing patterns
|
||||
2. Evaluate implementation options with trade-offs
|
||||
3. Make architectural decisions
|
||||
4. Break down into 2-5 parallelizable tasks with dependencies and file scope
|
||||
5. Classify each task with a single `type`: `default` / `ui` / `quick-fix`
|
||||
6. Determine if UI work is needed (check for .css/.tsx/.vue files)
|
||||
|
||||
Output the analysis following the structure below.
|
||||
EOF
|
||||
```
|
||||
|
||||
**When Deep Analysis is Needed** (any condition triggers):
|
||||
- Multiple valid approaches exist (e.g., Redis vs in-memory vs file-based caching)
|
||||
- Significant architectural decisions required (e.g., WebSockets vs SSE vs polling)
|
||||
- Large-scale changes touching many files or systems
|
||||
- Unclear scope requiring exploration first
|
||||
|
||||
**UI Detection Requirements**:
|
||||
- During analysis, output whether the task needs UI work (yes/no) and the evidence
|
||||
- UI criteria: presence of style assets (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component files (.tsx, .jsx, .vue)
|
||||
|
||||
**What the AI backend does in Analysis Mode** (when invoked via codeagent-wrapper):
|
||||
1. **Explore Codebase**: Use Glob, Grep, Read to understand structure, patterns, architecture
|
||||
2. **Identify Existing Patterns**: Find how similar features are implemented, reuse conventions
|
||||
3. **Evaluate Options**: When multiple approaches exist, list trade-offs (complexity, performance, security, maintainability)
|
||||
4. **Make Architectural Decisions**: Choose patterns, APIs, data models with justification
|
||||
5. **Design Task Breakdown**: Produce parallelizable tasks based on natural functional boundaries with file scope and dependencies
|
||||
|
||||
**Analysis Output Structure**:
|
||||
```
|
||||
## Context & Constraints
|
||||
[Tech stack, existing patterns, constraints discovered]
|
||||
|
||||
## Codebase Exploration
|
||||
[Key files, modules, patterns found via Glob/Grep/Read]
|
||||
|
||||
## Implementation Options (if multiple approaches)
|
||||
| Option | Pros | Cons | Recommendation |
|
||||
|
||||
## Technical Decisions
|
||||
[API design, data models, architecture choices made]
|
||||
|
||||
## Task Breakdown
|
||||
[2-5 tasks with: ID, description, file scope, dependencies, test command, type(default|ui|quick-fix)]
|
||||
|
||||
## UI Determination
|
||||
needs_ui: [true/false]
|
||||
evidence: [files and reasoning tied to style + component criteria]
|
||||
```
|
||||
|
||||
**Skip Deep Analysis When**:
|
||||
- Simple, straightforward implementation with obvious approach
|
||||
- Small changes confined to 1-2 files
|
||||
- Clear requirements with single implementation path
|
||||
|
||||
- **Step 3: Generate Development Documentation**
|
||||
- Use Task tool to invoke develop-doc-generator:
|
||||
```
|
||||
基于以下分析结果生成开发文档:
|
||||
- invoke agent dev-plan-generator
|
||||
- When creating `dev-plan.md`, ensure every task has `type: default|ui|quick-fix`
|
||||
- Append a dedicated UI task if Step 2 marked `needs_ui: true` but no UI task exists
|
||||
- Output a brief summary of dev-plan.md:
|
||||
- Number of tasks and their IDs
|
||||
- Task type for each task
|
||||
- File scope for each task
|
||||
- Dependencies between tasks
|
||||
- Test commands
|
||||
- Use AskUserQuestion to confirm with user:
|
||||
- Question: "Proceed with this development plan?" (state backend routing rules and any forced fallback due to allowed_backends)
|
||||
- Options: "Confirm and execute" / "Need adjustments"
|
||||
- If user chooses "Need adjustments", return to Step 1 or Step 2 based on feedback
|
||||
|
||||
[Codex 分析输出]
|
||||
|
||||
输出文件:./.claude/specs/{feature_name}/dev-plan.md
|
||||
|
||||
包含:
|
||||
1. 功能概述
|
||||
2. 任务列表(2-5个并发任务)
|
||||
- 每个任务:ID、描述、文件范围、依赖、测试命令
|
||||
3. 验收标准
|
||||
4. 覆盖率要求:≥90%
|
||||
```
|
||||
|
||||
- **Step 4: Parallel Development Execution**
|
||||
- For each task in `dev-plan.md` run:
|
||||
- **Step 4: Parallel Development Execution [CODEAGENT-WRAPPER ONLY - NO DIRECT EDITS]**
|
||||
- MUST use Bash tool to invoke `codeagent-wrapper --parallel` for ALL code changes
|
||||
- NEVER use Edit, Write, MultiEdit, or Task tools to modify code directly
|
||||
- Backend routing (must be deterministic and enforceable):
|
||||
- Task field: `type: default|ui|quick-fix` (missing → treat as `default`)
|
||||
- Preferred backend by type:
|
||||
- `default` → `codex`
|
||||
- `ui` → `gemini` (enforced when allowed)
|
||||
- `quick-fix` → `claude`
|
||||
- If user selected `仅 codex`: all tasks MUST use `codex`
|
||||
- Otherwise, if preferred backend is not in `allowed_backends`, fallback to the first available backend by priority: `codex` → `claude` → `gemini`
|
||||
- Build ONE `--parallel` config that includes all tasks in `dev-plan.md` and submit it once via Bash tool:
|
||||
```bash
|
||||
uv run ~/.claude/skills/codex/scripts/codex.py "实现任务:[任务ID]
|
||||
# One shot submission - wrapper handles topology + concurrency
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: [task-id-1]
|
||||
backend: [routed-backend-from-type-and-allowed_backends]
|
||||
workdir: .
|
||||
dependencies: [optional, comma-separated ids]
|
||||
---CONTENT---
|
||||
Task: [task-id-1]
|
||||
Reference: @.claude/specs/{feature_name}/dev-plan.md
|
||||
Scope: [task file scope]
|
||||
Test: [test command]
|
||||
Deliverables: code + unit tests + coverage ≥90% + coverage summary
|
||||
|
||||
参考文档:@.claude/specs/{feature_name}/dev-plan.md
|
||||
|
||||
你的职责:
|
||||
1. 实现功能代码
|
||||
2. 编写单元测试
|
||||
3. 运行测试 + 覆盖率
|
||||
4. 报告覆盖率结果
|
||||
|
||||
文件范围:[任务的文件范围]
|
||||
测试命令:[任务指定的测试命令]
|
||||
覆盖率目标:≥90%
|
||||
" "gpt-5.1-codex"
|
||||
---TASK---
|
||||
id: [task-id-2]
|
||||
backend: [routed-backend-from-type-and-allowed_backends]
|
||||
workdir: .
|
||||
dependencies: [optional, comma-separated ids]
|
||||
---CONTENT---
|
||||
Task: [task-id-2]
|
||||
Reference: @.claude/specs/{feature_name}/dev-plan.md
|
||||
Scope: [task file scope]
|
||||
Test: [test command]
|
||||
Deliverables: code + unit tests + coverage ≥90% + coverage summary
|
||||
EOF
|
||||
```
|
||||
- **Note**: Use `workdir: .` (current directory) for all tasks unless specific subdirectory is required
|
||||
- Execute independent tasks concurrently; serialize conflicting ones; track coverage reports
|
||||
- Backend is routed deterministically based on task `type`, no manual intervention needed
|
||||
|
||||
- **Step 5: Coverage Validation**
|
||||
- Validate each task’s coverage:
|
||||
@@ -87,13 +189,19 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
|
||||
- Provide completed task list, coverage per task, key file changes
|
||||
|
||||
**Error Handling**
|
||||
- Codex failure: retry once, then log and continue
|
||||
- Insufficient coverage: request more tests (max 2 rounds)
|
||||
- Dependency conflicts: serialize automatically
|
||||
- **codeagent-wrapper failure**: Retry once with same input; if still fails, log error and ask user for guidance
|
||||
- **Insufficient coverage (<90%)**: Request more tests from the failed task (max 2 rounds); if still fails, report to user
|
||||
- **Dependency conflicts**:
|
||||
- Circular dependencies: codeagent-wrapper will detect and fail with error; revise task breakdown to remove cycles
|
||||
- Missing dependencies: Ensure all task IDs referenced in `dependencies` field exist
|
||||
- **Parallel execution timeout**: Individual tasks timeout after 2 hours (configurable via CODEX_TIMEOUT); failed tasks can be retried individually
|
||||
- **Backend unavailable**: If a routed backend is unavailable, fallback to another backend in `allowed_backends` (priority: codex → claude → gemini); if none works, fail with a clear error message
|
||||
|
||||
**Quality Standards**
|
||||
- Code coverage ≥90%
|
||||
- 2-5 genuinely parallelizable tasks
|
||||
- Tasks based on natural functional boundaries (typically 2-5)
|
||||
- Each task has exactly one `type: default|ui|quick-fix`
|
||||
- Backend routed by `type`: `default`→codex, `ui`→gemini, `quick-fix`→claude (with allowed_backends fallback)
|
||||
- Documentation must be minimal yet actionable
|
||||
- No verbose implementations; only essential code
|
||||
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"name": "development-essentials",
|
||||
"source": "./",
|
||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"code",
|
||||
"debug",
|
||||
"test",
|
||||
"optimize",
|
||||
"review",
|
||||
"bugfix",
|
||||
"refactor",
|
||||
"documentation"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/code.md",
|
||||
"./commands/debug.md",
|
||||
"./commands/test.md",
|
||||
"./commands/optimize.md",
|
||||
"./commands/review.md",
|
||||
"./commands/bugfix.md",
|
||||
"./commands/refactor.md",
|
||||
"./commands/docs.md",
|
||||
"./commands/ask.md",
|
||||
"./commands/think.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/code.md",
|
||||
"./agents/bugfix.md",
|
||||
"./agents/bugfix-verify.md",
|
||||
"./agents/optimize.md",
|
||||
"./agents/debug.md"
|
||||
]
|
||||
}
|
||||
9
development-essentials/.claude-plugin/plugin.json
Normal file
9
development-essentials/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "essentials",
|
||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
# Advanced AI Agents Guide
|
||||
|
||||
> GPT-5 deep reasoning integration for complex analysis and architectural decisions
|
||||
|
||||
## 🎯 Overview
|
||||
|
||||
The Advanced AI Agents plugin provides access to GPT-5's deep reasoning capabilities through the `gpt5` agent, designed for complex problem-solving that requires multi-step thinking and comprehensive analysis.
|
||||
|
||||
## 🤖 GPT-5 Agent
|
||||
|
||||
### Capabilities
|
||||
|
||||
The `gpt5` agent excels at:
|
||||
|
||||
- **Architectural Analysis**: Evaluating system designs and scalability concerns
|
||||
- **Strategic Planning**: Breaking down complex initiatives into actionable plans
|
||||
- **Trade-off Analysis**: Comparing multiple approaches with detailed pros/cons
|
||||
- **Problem Decomposition**: Breaking complex problems into manageable components
|
||||
- **Deep Reasoning**: Multi-step logical analysis for non-obvious solutions
|
||||
- **Technology Evaluation**: Assessing technologies, frameworks, and tools
|
||||
|
||||
### When to Use
|
||||
|
||||
**Use GPT-5 agent** when:
|
||||
- Problem requires deep, multi-step reasoning
|
||||
- Multiple solution approaches need evaluation
|
||||
- Architectural decisions have long-term impact
|
||||
- Trade-offs are complex and multifaceted
|
||||
- Standard agents provide insufficient depth
|
||||
|
||||
**Use standard agents** when:
|
||||
- Task is straightforward implementation
|
||||
- Requirements are clear and well-defined
|
||||
- Quick turnaround is priority
|
||||
- Problem is domain-specific (code, tests, etc.)
|
||||
|
||||
## 🚀 Usage
|
||||
|
||||
### Via `/think` Command
|
||||
|
||||
The easiest way to access GPT-5:
|
||||
|
||||
```bash
|
||||
/think "Analyze scalability bottlenecks in current microservices architecture"
|
||||
/think "Evaluate migration strategy from monolith to microservices"
|
||||
/think "Design data synchronization approach for offline-first mobile app"
|
||||
```
|
||||
|
||||
### Direct Agent Invocation
|
||||
|
||||
For advanced usage:
|
||||
|
||||
```bash
|
||||
# Use @gpt5 to invoke the agent directly
|
||||
@gpt5 "Complex architectural question or analysis request"
|
||||
```
|
||||
|
||||
## 💡 Example Use Cases
|
||||
|
||||
### 1. Architecture Evaluation
|
||||
|
||||
```bash
|
||||
/think "Current system uses REST API with polling for real-time updates.
|
||||
Evaluate whether to migrate to WebSocket, Server-Sent Events, or GraphQL
|
||||
subscriptions. Consider: team experience, existing infrastructure, client
|
||||
support, scalability, and implementation effort."
|
||||
```
|
||||
|
||||
**GPT-5 provides**:
|
||||
- Detailed analysis of each option
|
||||
- Pros and cons for your specific context
|
||||
- Migration complexity assessment
|
||||
- Performance implications
|
||||
- Recommended approach with justification
|
||||
|
||||
### 2. Migration Strategy
|
||||
|
||||
```bash
|
||||
/think "Plan migration from PostgreSQL to multi-region distributed database.
|
||||
System has 50M users, 200M rows, 1000 req/sec. Must maintain 99.9% uptime.
|
||||
What's the safest migration path?"
|
||||
```
|
||||
|
||||
**GPT-5 provides**:
|
||||
- Step-by-step migration plan
|
||||
- Risk assessment for each phase
|
||||
- Rollback strategies
|
||||
- Data consistency approaches
|
||||
- Timeline estimation
|
||||
|
||||
### 3. Problem Decomposition
|
||||
|
||||
```bash
|
||||
/think "Design a recommendation engine that learns user preferences, handles
|
||||
cold start, provides explainable results, and scales to 10M users. Break this
|
||||
down into implementation phases with clear milestones."
|
||||
```
|
||||
|
||||
**GPT-5 provides**:
|
||||
- Problem breakdown into components
|
||||
- Phased implementation plan
|
||||
- Technical approach for each phase
|
||||
- Dependencies between phases
|
||||
- Success criteria and metrics
|
||||
|
||||
### 4. Technology Selection
|
||||
|
||||
```bash
|
||||
/think "Choosing between Redis, Memcached, and Hazelcast for distributed
|
||||
caching. System needs: persistence, pub/sub, clustering, and complex data
|
||||
structures. Existing stack: Java, Kubernetes, AWS."
|
||||
```
|
||||
|
||||
**GPT-5 provides**:
|
||||
- Comparison matrix across requirements
|
||||
- Integration considerations
|
||||
- Operational complexity analysis
|
||||
- Cost implications
|
||||
- Recommendation with rationale
|
||||
|
||||
### 5. Performance Optimization
|
||||
|
||||
```bash
|
||||
/think "API response time increased from 100ms to 800ms after scaling from
|
||||
100 to 10,000 users. Database queries look optimized. What are the likely
|
||||
bottlenecks and systematic approach to identify them?"
|
||||
```
|
||||
|
||||
**GPT-5 provides**:
|
||||
- Hypothesis generation (N+1 queries, connection pooling, etc.)
|
||||
- Systematic debugging approach
|
||||
- Profiling strategy
|
||||
- Likely root causes ranked by probability
|
||||
- Optimization recommendations
|
||||
|
||||
## 🎨 Integration with BMAD
|
||||
|
||||
### Enhanced Code Review
|
||||
|
||||
BMAD's `bmad-review` agent can optionally use GPT-5 for deeper analysis:
|
||||
|
||||
**Configuration**:
|
||||
```bash
|
||||
# Enable enhanced review mode (via environment or BMAD config)
|
||||
BMAD_REVIEW_MODE=enhanced /bmad-pilot "feature description"
|
||||
```
|
||||
|
||||
**What changes**:
|
||||
- Standard review: Fast, focuses on code quality and obvious issues
|
||||
- Enhanced review: Deep analysis including:
|
||||
- Architectural impact
|
||||
- Security implications
|
||||
- Performance considerations
|
||||
- Scalability concerns
|
||||
- Design pattern appropriateness
|
||||
|
||||
### Architecture Phase Support
|
||||
|
||||
Use `/think` during BMAD architecture phase:
|
||||
|
||||
```bash
|
||||
# Start BMAD workflow
|
||||
/bmad-pilot "E-commerce platform with real-time inventory"
|
||||
|
||||
# During Architecture phase, get deep analysis
|
||||
/think "Evaluate architecture approaches for real-time inventory
|
||||
synchronization across warehouses, online store, and mobile apps"
|
||||
|
||||
# Continue with BMAD using insights
|
||||
```
|
||||
|
||||
## 📋 Best Practices
|
||||
|
||||
### 1. Provide Complete Context
|
||||
|
||||
**❌ Insufficient**:
|
||||
```bash
|
||||
/think "Should we use microservices?"
|
||||
```
|
||||
|
||||
**✅ Complete**:
|
||||
```bash
|
||||
/think "Current monolith: 100K LOC, 8 developers, 50K users, 200ms avg
|
||||
response time. Pain points: slow deployments (1hr), difficult to scale
|
||||
components independently. Should we migrate to microservices? What's the
|
||||
ROI and risk?"
|
||||
```
|
||||
|
||||
### 2. Ask Specific Questions
|
||||
|
||||
**❌ Too broad**:
|
||||
```bash
|
||||
/think "How to build a scalable system?"
|
||||
```
|
||||
|
||||
**✅ Specific**:
|
||||
```bash
|
||||
/think "Current system handles 1K req/sec. Need to scale to 10K. Bottleneck
|
||||
is database writes. Evaluate: sharding, read replicas, CQRS, or caching.
|
||||
Database: PostgreSQL, stack: Node.js, deployment: Kubernetes."
|
||||
```
|
||||
|
||||
### 3. Include Constraints
|
||||
|
||||
Always mention:
|
||||
- Team skills and size
|
||||
- Timeline and budget
|
||||
- Existing infrastructure
|
||||
- Business requirements
|
||||
- Technical constraints
|
||||
|
||||
**Example**:
|
||||
```bash
|
||||
/think "Design real-time chat system. Constraints: team of 3 backend
|
||||
developers (Node.js), 6-month timeline, AWS deployment, must integrate
|
||||
with existing REST API, budget for managed services OK."
|
||||
```
|
||||
|
||||
### 4. Request Specific Outputs
|
||||
|
||||
Tell GPT-5 what format you need:
|
||||
|
||||
```bash
|
||||
/think "Compare Kafka vs RabbitMQ for event streaming.
|
||||
Provide: comparison table, recommendation, migration complexity from current
|
||||
RabbitMQ setup, and estimated effort in developer-weeks."
|
||||
```
|
||||
|
||||
### 5. Iterate and Refine
|
||||
|
||||
Follow up for deeper analysis:
|
||||
|
||||
```bash
|
||||
# Initial question
|
||||
/think "Evaluate caching strategies for user profile API"
|
||||
|
||||
# Follow-up based on response
|
||||
/think "You recommended Redis with write-through caching. How to handle
|
||||
cache invalidation when user updates profile from mobile app?"
|
||||
```
|
||||
|
||||
## 🔧 Technical Details
|
||||
|
||||
### Sequential Thinking
|
||||
|
||||
GPT-5 agent uses sequential thinking for complex problems:
|
||||
|
||||
1. **Problem Understanding**: Clarify requirements and constraints
|
||||
2. **Hypothesis Generation**: Identify possible solutions
|
||||
3. **Analysis**: Evaluate each option systematically
|
||||
4. **Trade-off Assessment**: Compare pros/cons
|
||||
5. **Recommendation**: Provide justified conclusion
|
||||
|
||||
### Reasoning Transparency
|
||||
|
||||
GPT-5 shows its thinking process:
|
||||
- Assumptions made
|
||||
- Factors considered
|
||||
- Why certain options were eliminated
|
||||
- Confidence level in recommendations
|
||||
|
||||
## 🎯 Comparison: GPT-5 vs Standard Agents
|
||||
|
||||
| Aspect | GPT-5 Agent | Standard Agents |
|
||||
|--------|-------------|-----------------|
|
||||
| **Depth** | Deep, multi-step reasoning | Focused, domain-specific |
|
||||
| **Speed** | Slower (comprehensive analysis) | Faster (direct implementation) |
|
||||
| **Use Case** | Strategic decisions, architecture | Implementation, coding, testing |
|
||||
| **Output** | Analysis, recommendations, plans | Code, tests, documentation |
|
||||
| **Best For** | Complex problems, trade-offs | Clear tasks, defined scope |
|
||||
| **Invocation** | `/think` or `@gpt5` | `/code`, `/test`, etc. |
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- **[BMAD Workflow](BMAD-WORKFLOW.md)** - Integration with full agile workflow
|
||||
- **[Development Commands](DEVELOPMENT-COMMANDS.md)** - Standard command reference
|
||||
- **[Quick Start Guide](QUICK-START.md)** - Get started quickly
|
||||
|
||||
## 💡 Advanced Patterns
|
||||
|
||||
### Pre-Implementation Analysis
|
||||
|
||||
```bash
|
||||
# 1. Deep analysis with GPT-5
|
||||
/think "Design approach for X with constraints Y and Z"
|
||||
|
||||
# 2. Use analysis in BMAD workflow
|
||||
/bmad-pilot "Implement X based on approach from analysis"
|
||||
```
|
||||
|
||||
### Architecture Validation
|
||||
|
||||
```bash
|
||||
# 1. Get initial architecture from BMAD
|
||||
/bmad-pilot "Feature X" # Generates 02-system-architecture.md
|
||||
|
||||
# 2. Validate with GPT-5
|
||||
/think "Review architecture in .claude/specs/feature-x/02-system-architecture.md
|
||||
Evaluate for scalability, security, and maintainability"
|
||||
|
||||
# 3. Refine architecture based on feedback
|
||||
```
|
||||
|
||||
### Decision Documentation
|
||||
|
||||
```bash
|
||||
# Use GPT-5 to document architectural decisions
|
||||
/think "Document decision to use Event Sourcing for order management.
|
||||
Include: context, options considered, decision rationale, consequences,
|
||||
and format as Architecture Decision Record (ADR)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Advanced AI Agents** - Deep reasoning for complex problems that require comprehensive analysis.
|
||||
451
docs/CODEAGENT-WRAPPER.md
Normal file
451
docs/CODEAGENT-WRAPPER.md
Normal file
@@ -0,0 +1,451 @@
|
||||
# Codeagent-Wrapper User Guide
|
||||
|
||||
Multi-backend AI code execution wrapper supporting Codex, Claude, and Gemini.
|
||||
|
||||
## Overview
|
||||
|
||||
`codeagent-wrapper` is a Go-based CLI tool that provides a unified interface to multiple AI coding backends. It handles:
|
||||
- Multi-backend execution (Codex, Claude, Gemini)
|
||||
- JSON stream parsing and output formatting
|
||||
- Session management and resumption
|
||||
- Parallel task execution with dependency resolution
|
||||
- Timeout handling and signal forwarding
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# Clone repository
|
||||
git clone https://github.com/cexll/myclaude.git
|
||||
cd myclaude
|
||||
|
||||
# Install via install.py (includes binary compilation)
|
||||
python3 install.py --module dev
|
||||
|
||||
# Or manual installation
|
||||
cd codeagent-wrapper
|
||||
go build -o ~/.claude/bin/codeagent-wrapper
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Simple task (default: codex backend)
|
||||
codeagent-wrapper "explain @src/main.go"
|
||||
|
||||
# With backend selection
|
||||
codeagent-wrapper --backend claude "refactor @utils.ts"
|
||||
|
||||
# With HEREDOC (recommended for complex tasks)
|
||||
codeagent-wrapper --backend gemini - <<'EOF'
|
||||
Implement user authentication:
|
||||
- JWT tokens
|
||||
- Password hashing with bcrypt
|
||||
- Session management
|
||||
EOF
|
||||
```
|
||||
|
||||
### Backend Selection
|
||||
|
||||
| Backend | Command | Best For |
|
||||
|---------|---------|----------|
|
||||
| **Codex** | `--backend codex` | General code tasks (default) |
|
||||
| **Claude** | `--backend claude` | Complex reasoning, architecture |
|
||||
| **Gemini** | `--backend gemini` | Fast iteration, prototyping |
|
||||
|
||||
## Core Features
|
||||
|
||||
### 1. Multi-Backend Support
|
||||
|
||||
```bash
|
||||
# Codex (default)
|
||||
codeagent-wrapper "add logging to @app.js"
|
||||
|
||||
# Claude for architecture decisions
|
||||
codeagent-wrapper --backend claude - <<'EOF'
|
||||
Design a microservices architecture for e-commerce:
|
||||
- Service boundaries
|
||||
- Communication patterns
|
||||
- Data consistency strategy
|
||||
EOF
|
||||
|
||||
# Gemini for quick prototypes
|
||||
codeagent-wrapper --backend gemini "create React component for user profile"
|
||||
```
|
||||
|
||||
### 2. File References with @ Syntax
|
||||
|
||||
```bash
|
||||
# Single file
|
||||
codeagent-wrapper "optimize @src/utils.ts"
|
||||
|
||||
# Multiple files
|
||||
codeagent-wrapper "refactor @src/auth.ts and @src/middleware.ts"
|
||||
|
||||
# Entire directory
|
||||
codeagent-wrapper "analyze @src for security issues"
|
||||
```
|
||||
|
||||
### 3. Session Management
|
||||
|
||||
```bash
|
||||
# First task
|
||||
codeagent-wrapper "add validation to user form"
|
||||
# Output includes: SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14
|
||||
|
||||
# Resume session
|
||||
codeagent-wrapper resume 019a7247-ac9d-71f3-89e2-a823dbd8fd14 - <<'EOF'
|
||||
Now add error messages for each validation rule
|
||||
EOF
|
||||
```
|
||||
|
||||
### 4. Parallel Execution
|
||||
|
||||
Execute multiple tasks concurrently with dependency management:
|
||||
|
||||
```bash
|
||||
# Default: summary output (context-efficient, recommended)
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: backend_1701234567
|
||||
workdir: /project/backend
|
||||
---CONTENT---
|
||||
implement /api/users endpoints with CRUD operations
|
||||
|
||||
---TASK---
|
||||
id: frontend_1701234568
|
||||
workdir: /project/frontend
|
||||
---CONTENT---
|
||||
build Users page consuming /api/users
|
||||
|
||||
---TASK---
|
||||
id: tests_1701234569
|
||||
workdir: /project/tests
|
||||
dependencies: backend_1701234567, frontend_1701234568
|
||||
---CONTENT---
|
||||
add integration tests for user management flow
|
||||
EOF
|
||||
|
||||
# Full output mode (for debugging, includes complete task messages)
|
||||
codeagent-wrapper --parallel --full-output <<'EOF'
|
||||
...
|
||||
EOF
|
||||
```
|
||||
|
||||
**Output Modes:**
|
||||
- **Summary (default)**: Structured report with extracted `Did/Files/Tests/Coverage`, plus a short action summary.
|
||||
- **Full (`--full-output`)**: Complete task messages included. Use only for debugging.
|
||||
|
||||
**Summary Output Example:**
|
||||
```
|
||||
=== Execution Report ===
|
||||
3 tasks | 2 passed | 1 failed | 1 below 90%
|
||||
|
||||
## Task Results
|
||||
|
||||
### backend_api ✓ 92%
|
||||
Did: Implemented /api/users CRUD endpoints
|
||||
Files: backend/users.go, backend/router.go
|
||||
Tests: 12 passed
|
||||
Log: /tmp/codeagent-xxx.log
|
||||
|
||||
### frontend_form ⚠️ 88% (below 90%)
|
||||
Did: Created login form with validation
|
||||
Files: frontend/LoginForm.tsx
|
||||
Tests: 8 passed
|
||||
Gap: lines not covered: frontend/LoginForm.tsx:42-47
|
||||
Log: /tmp/codeagent-yyy.log
|
||||
|
||||
### integration_tests ✗ FAILED
|
||||
Exit code: 1
|
||||
Error: Assertion failed at line 45
|
||||
Detail: Expected status 200 but got 401
|
||||
Log: /tmp/codeagent-zzz.log
|
||||
|
||||
## Summary
|
||||
- 2/3 completed successfully
|
||||
- Fix: integration_tests (Assertion failed at line 45)
|
||||
- Coverage: frontend_form
|
||||
```
|
||||
|
||||
**Parallel Task Format:**
|
||||
- `---TASK---` - Starts task block
|
||||
- `id: <unique_id>` - Required, use `<feature>_<timestamp>` format
|
||||
- `workdir: <path>` - Optional, defaults to current directory
|
||||
- `dependencies: <id1>, <id2>` - Optional, comma-separated task IDs
|
||||
- `---CONTENT---` - Separates metadata from task content
|
||||
|
||||
**Features:**
|
||||
- Automatic topological sorting
|
||||
- Unlimited concurrency for independent tasks
|
||||
- Error isolation (failures don't stop other tasks)
|
||||
- Dependency blocking (skip if parent fails)
|
||||
|
||||
### 5. Working Directory
|
||||
|
||||
```bash
|
||||
# Execute in specific directory
|
||||
codeagent-wrapper "run tests" /path/to/project
|
||||
|
||||
# With backend selection
|
||||
codeagent-wrapper --backend claude "analyze code" /project/backend
|
||||
|
||||
# With HEREDOC
|
||||
codeagent-wrapper - /path/to/project <<'EOF'
|
||||
refactor database layer
|
||||
EOF
|
||||
```
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
### Timeout Control
|
||||
|
||||
```bash
|
||||
# Set custom timeout (1 hour = 3600000ms)
|
||||
CODEX_TIMEOUT=3600000 codeagent-wrapper "long running task"
|
||||
|
||||
# Default timeout: 7200000ms (2 hours)
|
||||
```
|
||||
|
||||
**Timeout behavior:**
|
||||
- Sends SIGTERM to backend process
|
||||
- Waits 5 seconds
|
||||
- Sends SIGKILL if process doesn't exit
|
||||
- Returns exit code 124 (consistent with GNU timeout)
|
||||
|
||||
### Complex Multi-line Tasks
|
||||
|
||||
Use HEREDOC to avoid shell escaping issues:
|
||||
|
||||
```bash
|
||||
codeagent-wrapper - <<'EOF'
|
||||
Refactor authentication system:
|
||||
|
||||
Current issues:
|
||||
- Password stored as plain text
|
||||
- No rate limiting on login
|
||||
- Sessions don't expire
|
||||
|
||||
Requirements:
|
||||
1. Hash passwords with bcrypt
|
||||
2. Add rate limiting (5 attempts/15min)
|
||||
3. Session expiry after 24h
|
||||
4. Add refresh token mechanism
|
||||
|
||||
Files to modify:
|
||||
- @src/auth/login.ts
|
||||
- @src/middleware/rateLimit.ts
|
||||
- @config/session.ts
|
||||
EOF
|
||||
```
|
||||
|
||||
### Backend-Specific Features
|
||||
|
||||
**Codex:**
|
||||
```bash
|
||||
# Best for code editing and refactoring
|
||||
codeagent-wrapper --backend codex - <<'EOF'
|
||||
extract duplicate code in @src into reusable helpers
|
||||
EOF
|
||||
```
|
||||
|
||||
**Claude:**
|
||||
```bash
|
||||
# Best for complex reasoning
|
||||
codeagent-wrapper --backend claude - <<'EOF'
|
||||
review @src/payment/processor.ts for:
|
||||
- Race conditions
|
||||
- Edge cases
|
||||
- Security vulnerabilities
|
||||
EOF
|
||||
```
|
||||
|
||||
**Gemini:**
|
||||
```bash
|
||||
# Best for fast iteration
|
||||
codeagent-wrapper --backend gemini "add TypeScript types to @api.js"
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Standard output includes parsed agent messages and session ID:
|
||||
|
||||
```
|
||||
Agent response text here...
|
||||
Implementation details...
|
||||
|
||||
---
|
||||
SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14
|
||||
```
|
||||
|
||||
Error output (stderr):
|
||||
```
|
||||
ERROR: Error message details
|
||||
```
|
||||
|
||||
Parallel execution output:
|
||||
```
|
||||
=== Parallel Execution Summary ===
|
||||
Total: 3 | Success: 2 | Failed: 1
|
||||
|
||||
--- Task: backend_1701234567 ---
|
||||
Status: SUCCESS
|
||||
Session: 019a7247-ac9d-71f3-89e2-a823dbd8fd14
|
||||
|
||||
Implementation complete...
|
||||
|
||||
--- Task: frontend_1701234568 ---
|
||||
Status: SUCCESS
|
||||
Session: 019a7248-ac9d-71f3-89e2-a823dbd8fd14
|
||||
|
||||
UI components created...
|
||||
|
||||
--- Task: tests_1701234569 ---
|
||||
Status: FAILED (exit code 1)
|
||||
Error: dependency backend_1701234567 failed
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| 0 | Success |
|
||||
| 1 | General error (missing args, no output) |
|
||||
| 124 | Timeout |
|
||||
| 127 | Backend command not found |
|
||||
| 130 | Interrupted (Ctrl+C) |
|
||||
| * | Passthrough from backend process |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CODEX_TIMEOUT` | 7200000 | Timeout in milliseconds |
|
||||
| `CODEX_BYPASS_SANDBOX` | true | Bypass Codex sandbox/approval. Set `false` to disable |
|
||||
| `CODEAGENT_SKIP_PERMISSIONS` | true | Skip Claude permission prompts. Set `false` to disable |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Backend not found:**
|
||||
```bash
|
||||
# Ensure backend CLI is installed
|
||||
which codex
|
||||
which claude
|
||||
which gemini
|
||||
|
||||
# Check PATH
|
||||
echo $PATH
|
||||
```
|
||||
|
||||
**Timeout too short:**
|
||||
```bash
|
||||
# Increase timeout to 4 hours
|
||||
CODEX_TIMEOUT=14400000 codeagent-wrapper "complex task"
|
||||
```
|
||||
|
||||
**Session ID not found:**
|
||||
```bash
|
||||
# List recent sessions (backend-specific)
|
||||
codex history
|
||||
|
||||
# Ensure session ID is copied correctly
|
||||
codeagent-wrapper resume <session_id> "continue task"
|
||||
```
|
||||
|
||||
**Parallel tasks not running:**
|
||||
```bash
|
||||
# Check task format
|
||||
# Ensure ---TASK--- and ---CONTENT--- delimiters are correct
|
||||
# Verify task IDs are unique
|
||||
# Check dependencies reference existing task IDs
|
||||
```
|
||||
|
||||
## Integration with Claude Code
|
||||
|
||||
Use via the `codeagent` skill:
|
||||
|
||||
```bash
|
||||
# In Claude Code conversation
|
||||
User: Use codeagent to implement authentication
|
||||
|
||||
# Claude will execute:
|
||||
codeagent-wrapper --backend codex - <<'EOF'
|
||||
implement JWT authentication in @src/auth
|
||||
EOF
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Use parallel execution** for independent tasks
|
||||
2. **Choose the right backend** for the task type
|
||||
3. **Keep working directory specific** to reduce context
|
||||
4. **Resume sessions** for multi-step workflows
|
||||
5. **Use @ syntax** to minimize file content in prompts
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **HEREDOC for complex tasks** - Avoid shell escaping nightmares
|
||||
2. **Descriptive task IDs** - Use `<feature>_<timestamp>` format
|
||||
3. **Absolute paths** - Avoid relative path confusion
|
||||
4. **Session resumption** - Continue conversations with context
|
||||
5. **Timeout tuning** - Set appropriate timeouts for task complexity
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Code Review
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --backend claude - <<'EOF'
|
||||
Review @src/payment/stripe.ts for:
|
||||
1. Security issues (API key handling, input validation)
|
||||
2. Error handling (network failures, API errors)
|
||||
3. Edge cases (duplicate charges, partial refunds)
|
||||
4. Code quality (naming, structure, comments)
|
||||
EOF
|
||||
```
|
||||
|
||||
### Example 2: Refactoring
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --backend codex - <<'EOF'
|
||||
Refactor @src/utils:
|
||||
- Extract duplicate code into helpers
|
||||
- Add TypeScript types
|
||||
- Improve function naming
|
||||
- Add JSDoc comments
|
||||
EOF
|
||||
```
|
||||
|
||||
### Example 3: Full-Stack Feature
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: api_1701234567
|
||||
workdir: /project/backend
|
||||
---CONTENT---
|
||||
implement /api/notifications endpoints with WebSocket support
|
||||
|
||||
---TASK---
|
||||
id: ui_1701234568
|
||||
workdir: /project/frontend
|
||||
dependencies: api_1701234567
|
||||
---CONTENT---
|
||||
build Notifications component with real-time updates
|
||||
|
||||
---TASK---
|
||||
id: tests_1701234569
|
||||
workdir: /project
|
||||
dependencies: api_1701234567, ui_1701234568
|
||||
---CONTENT---
|
||||
add E2E tests for notification flow
|
||||
EOF
|
||||
```
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Codex CLI Documentation](https://codex.docs)
|
||||
- [Claude CLI Documentation](https://claude.ai/docs)
|
||||
- [Gemini CLI Documentation](https://ai.google.dev/docs)
|
||||
- [Architecture Overview](./architecture.md)
|
||||
197
docs/HOOKS.md
Normal file
197
docs/HOOKS.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Claude Code Hooks Guide
|
||||
|
||||
Hooks are shell scripts or commands that execute in response to Claude Code events.
|
||||
|
||||
## Available Hook Types
|
||||
|
||||
### 1. UserPromptSubmit
|
||||
Runs after user submits a prompt, before Claude processes it.
|
||||
|
||||
**Use cases:**
|
||||
- Auto-activate skills based on keywords
|
||||
- Add context injection
|
||||
- Log user requests
|
||||
|
||||
### 2. PostToolUse
|
||||
Runs after Claude uses a tool.
|
||||
|
||||
**Use cases:**
|
||||
- Validate tool outputs
|
||||
- Run additional checks (linting, formatting)
|
||||
- Log tool usage
|
||||
|
||||
### 3. Stop
|
||||
Runs when Claude Code session ends.
|
||||
|
||||
**Use cases:**
|
||||
- Cleanup temporary files
|
||||
- Generate session reports
|
||||
- Commit changes automatically
|
||||
|
||||
## Configuration
|
||||
|
||||
Hooks are configured in `.claude/settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/hooks/skill-activation-prompt.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"PostToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/hooks/post-tool-check.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Creating Custom Hooks
|
||||
|
||||
### Example: Pre-Commit Hook
|
||||
|
||||
**File:** `hooks/pre-commit.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Get staged files
|
||||
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
|
||||
|
||||
# Run tests on Go files
|
||||
GO_FILES=$(echo "$STAGED_FILES" | grep '\.go$' || true)
|
||||
if [ -n "$GO_FILES" ]; then
|
||||
go test ./... -short || exit 1
|
||||
fi
|
||||
|
||||
# Validate JSON files
|
||||
JSON_FILES=$(echo "$STAGED_FILES" | grep '\.json$' || true)
|
||||
if [ -n "$JSON_FILES" ]; then
|
||||
for file in $JSON_FILES; do
|
||||
jq empty "$file" || exit 1
|
||||
done
|
||||
fi
|
||||
|
||||
echo "✅ Pre-commit checks passed"
|
||||
```
|
||||
|
||||
**Register in settings.json:**
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/hooks/pre-commit.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example: Auto-Format Hook
|
||||
|
||||
**File:** `hooks/auto-format.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Format Go files
|
||||
find . -name "*.go" -exec gofmt -w {} \;
|
||||
|
||||
# Format JSON files
|
||||
find . -name "*.json" -exec jq --indent 2 . {} \; -exec mv {} {}.tmp \; -exec mv {}.tmp {} \;
|
||||
|
||||
echo "✅ Files formatted"
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Hooks have access to:
|
||||
- `$CLAUDE_PROJECT_DIR` - Project root directory
|
||||
- `$PWD` - Current working directory
|
||||
- All shell environment variables
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Keep hooks fast** - Slow hooks block Claude Code
|
||||
2. **Handle errors gracefully** - Return non-zero on failure
|
||||
3. **Use absolute paths** - Reference `$CLAUDE_PROJECT_DIR`
|
||||
4. **Make scripts executable** - `chmod +x hooks/script.sh`
|
||||
5. **Test independently** - Run hooks manually first
|
||||
6. **Document behavior** - Add comments explaining logic
|
||||
|
||||
## Debugging Hooks
|
||||
|
||||
Enable verbose logging:
|
||||
|
||||
```bash
|
||||
# Add to your hook
|
||||
set -x # Print commands
|
||||
set -e # Exit on error
|
||||
```
|
||||
|
||||
Test manually:
|
||||
|
||||
```bash
|
||||
cd /path/to/project
|
||||
./hooks/your-hook.sh
|
||||
echo $? # Check exit code
|
||||
```
|
||||
|
||||
## Built-in Hooks
|
||||
|
||||
This repository includes:
|
||||
|
||||
| Hook | File | Purpose |
|
||||
|------|------|---------|
|
||||
| Skill Activation | `skill-activation-prompt.sh` | Auto-suggest skills |
|
||||
| Pre-commit | `pre-commit.sh` | Code quality checks |
|
||||
|
||||
## Disabling Hooks
|
||||
|
||||
Remove hook configuration from `.claude/settings.json` or set empty array:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"UserPromptSubmit": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Hook not running?**
|
||||
- Check `.claude/settings.json` syntax
|
||||
- Verify script is executable: `ls -l hooks/`
|
||||
- Check script path is correct
|
||||
|
||||
**Hook failing silently?**
|
||||
- Add `set -e` to script
|
||||
- Check exit codes: `echo $?`
|
||||
- Add logging: `echo "debug" >> /tmp/hook.log`
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [Claude Code Hooks Documentation](https://docs.anthropic.com/claude-code/hooks)
|
||||
- [Bash Scripting Guide](https://www.gnu.org/software/bash/manual/)
|
||||
@@ -104,7 +104,7 @@ This repository provides 4 ready-to-use Claude Code plugins that can be installe
|
||||
|
||||
```bash
|
||||
# Install from GitHub repository
|
||||
/plugin github.com/cexll/myclaude
|
||||
/plugin marketplace add cexll/myclaude
|
||||
```
|
||||
|
||||
This will present all available plugins from the repository.
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
```bash
|
||||
# Install everything with one command
|
||||
/plugin github.com/cexll/myclaude
|
||||
/plugin marketplace add cexll/myclaude
|
||||
```
|
||||
|
||||
### Option 2: Make Install
|
||||
|
||||
12
hooks/hooks-config.json
Normal file
12
hooks/hooks-config.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "$CLAUDE_PROJECT_DIR/hooks/skill-activation-prompt.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
82
hooks/pre-commit.sh
Executable file
82
hooks/pre-commit.sh
Executable file
@@ -0,0 +1,82 @@
|
||||
#!/bin/bash
|
||||
# Example pre-commit hook
|
||||
# This hook runs before git commit to validate code quality
|
||||
|
||||
set -e
|
||||
|
||||
# Get staged files
|
||||
STAGED_FILES="$(git diff --cached --name-only --diff-filter=ACM)"
|
||||
|
||||
if [ -z "$STAGED_FILES" ]; then
|
||||
echo "No files to validate"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Running pre-commit checks..."
|
||||
|
||||
# Check Go files
|
||||
GO_FILES="$(printf '%s\n' "$STAGED_FILES" | grep '\.go$' || true)"
|
||||
if [ -n "$GO_FILES" ]; then
|
||||
echo "Checking Go files..."
|
||||
|
||||
if ! command -v gofmt &> /dev/null; then
|
||||
echo "❌ gofmt not found. Please install Go (gofmt is included with the Go toolchain)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Format check
|
||||
GO_FILE_ARGS=()
|
||||
while IFS= read -r file; do
|
||||
if [ -n "$file" ]; then
|
||||
GO_FILE_ARGS+=("$file")
|
||||
fi
|
||||
done <<< "$GO_FILES"
|
||||
|
||||
if [ "${#GO_FILE_ARGS[@]}" -gt 0 ]; then
|
||||
UNFORMATTED="$(gofmt -l "${GO_FILE_ARGS[@]}")"
|
||||
if [ -n "$UNFORMATTED" ]; then
|
||||
echo "❌ The following files need formatting:"
|
||||
echo "$UNFORMATTED"
|
||||
echo "Run: gofmt -w <file>"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Run tests
|
||||
if command -v go &> /dev/null; then
|
||||
echo "Running go tests..."
|
||||
go test ./... -short || {
|
||||
echo "❌ Tests failed"
|
||||
exit 1
|
||||
}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Check JSON files
|
||||
JSON_FILES="$(printf '%s\n' "$STAGED_FILES" | grep '\.json$' || true)"
|
||||
if [ -n "$JSON_FILES" ]; then
|
||||
echo "Validating JSON files..."
|
||||
if ! command -v jq &> /dev/null; then
|
||||
echo "❌ jq not found. Please install jq to validate JSON files."
|
||||
exit 1
|
||||
fi
|
||||
while IFS= read -r file; do
|
||||
if [ -z "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
if ! jq empty "$file" 2>/dev/null; then
|
||||
echo "❌ Invalid JSON: $file"
|
||||
exit 1
|
||||
fi
|
||||
done <<< "$JSON_FILES"
|
||||
fi
|
||||
|
||||
# Check Markdown files
|
||||
MD_FILES="$(printf '%s\n' "$STAGED_FILES" | grep '\.md$' || true)"
|
||||
if [ -n "$MD_FILES" ]; then
|
||||
echo "Checking markdown files..."
|
||||
# Add markdown linting if needed
|
||||
fi
|
||||
|
||||
echo "✅ All pre-commit checks passed"
|
||||
exit 0
|
||||
85
hooks/skill-activation-prompt.js
Normal file
85
hooks/skill-activation-prompt.js
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function readInput() {
|
||||
const raw = fs.readFileSync(0, "utf8").trim();
|
||||
if (!raw) return {};
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_err) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function extractPrompt(payload) {
|
||||
return (
|
||||
payload.prompt ||
|
||||
payload.text ||
|
||||
payload.userPrompt ||
|
||||
(payload.data && payload.data.prompt) ||
|
||||
""
|
||||
).toString();
|
||||
}
|
||||
|
||||
function loadRules() {
|
||||
const rulesPath = path.resolve(__dirname, "../skills/skill-rules.json");
|
||||
try {
|
||||
const file = fs.readFileSync(rulesPath, "utf8");
|
||||
return JSON.parse(file);
|
||||
} catch (_err) {
|
||||
return { skills: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function matchSkill(prompt, rule, skillName) {
|
||||
const triggers = (rule && rule.promptTriggers) || {};
|
||||
const keywords = [...(triggers.keywords || []), skillName].filter(Boolean);
|
||||
const patterns = triggers.intentPatterns || [];
|
||||
const promptLower = prompt.toLowerCase();
|
||||
|
||||
const keyword = keywords.find((k) => promptLower.includes(k.toLowerCase()));
|
||||
if (keyword) {
|
||||
return `命中关键词 "${keyword}"`;
|
||||
}
|
||||
|
||||
for (const pattern of patterns) {
|
||||
try {
|
||||
if (new RegExp(pattern, "i").test(prompt)) {
|
||||
return `命中模式 /${pattern}/`;
|
||||
}
|
||||
} catch (_err) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const payload = readInput();
|
||||
const prompt = extractPrompt(payload);
|
||||
if (!prompt.trim()) {
|
||||
console.log(JSON.stringify({ suggestedSkills: [] }, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const rules = loadRules();
|
||||
const suggestions = [];
|
||||
|
||||
for (const [name, rule] of Object.entries(rules.skills || {})) {
|
||||
const matchReason = matchSkill(prompt, rule, name);
|
||||
if (matchReason) {
|
||||
suggestions.push({
|
||||
skill: name,
|
||||
enforcement: rule.enforcement || "suggest",
|
||||
priority: rule.priority || "normal",
|
||||
reason: matchReason
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ suggestedSkills: suggestions }, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
12
hooks/skill-activation-prompt.sh
Executable file
12
hooks/skill-activation-prompt.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SCRIPT="$SCRIPT_DIR/skill-activation-prompt.js"
|
||||
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
node "$SCRIPT" "$@" || true
|
||||
else
|
||||
echo '{"suggestedSkills":[],"meta":{"warning":"node not found"}}'
|
||||
fi
|
||||
|
||||
exit 0
|
||||
77
hooks/test-skill-activation.sh
Executable file
77
hooks/test-skill-activation.sh
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Simple test runner for skill-activation-prompt hook.
|
||||
# Each case feeds JSON to the hook and validates suggested skills.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
HOOK_SCRIPT="$SCRIPT_DIR/skill-activation-prompt.sh"
|
||||
|
||||
parse_skills() {
|
||||
node -e 'const data = JSON.parse(require("fs").readFileSync(0, "utf8")); const skills = (data.suggestedSkills || []).map(s => s.skill); console.log(skills.join(" "));'
|
||||
}
|
||||
|
||||
run_case() {
|
||||
local name="$1"
|
||||
local input="$2"
|
||||
shift 2
|
||||
local expected=("$@")
|
||||
|
||||
local output skills
|
||||
output="$("$HOOK_SCRIPT" <<<"$input")"
|
||||
skills="$(printf "%s" "$output" | parse_skills)"
|
||||
|
||||
local pass=0
|
||||
if [[ ${#expected[@]} -eq 1 && ${expected[0]} == "none" ]]; then
|
||||
[[ -z "$skills" ]] && pass=1
|
||||
else
|
||||
pass=1
|
||||
for need in "${expected[@]}"; do
|
||||
if [[ " $skills " != *" $need "* ]]; then
|
||||
pass=0
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ $pass -eq 1 ]]; then
|
||||
echo "PASS: $name"
|
||||
else
|
||||
echo "FAIL: $name"
|
||||
echo " input: $input"
|
||||
echo " expected skills: ${expected[*]}"
|
||||
echo " actual skills: ${skills:-<empty>}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local status=0
|
||||
|
||||
run_case "keyword 'issue' => gh-workflow" \
|
||||
'{"prompt":"Please open an issue for this bug"}' \
|
||||
"gh-workflow" || status=1
|
||||
|
||||
run_case "keyword 'codex' => codex" \
|
||||
'{"prompt":"codex please handle this change"}' \
|
||||
"codex" || status=1
|
||||
|
||||
run_case "no matching keywords => none" \
|
||||
'{"prompt":"Just saying hello"}' \
|
||||
"none" || status=1
|
||||
|
||||
run_case "multiple keywords => codex & gh-workflow" \
|
||||
'{"prompt":"codex refactor then open an issue"}' \
|
||||
"codex" "gh-workflow" || status=1
|
||||
|
||||
if [[ $status -eq 0 ]]; then
|
||||
echo "All tests passed."
|
||||
else
|
||||
echo "Some tests failed."
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
197
install.bat
Normal file
197
install.bat
Normal file
@@ -0,0 +1,197 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
set "EXIT_CODE=0"
|
||||
set "REPO=cexll/myclaude"
|
||||
set "VERSION=latest"
|
||||
set "OS=windows"
|
||||
|
||||
call :detect_arch
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
set "BINARY_NAME=codeagent-wrapper-%OS%-%ARCH%.exe"
|
||||
set "URL=https://github.com/%REPO%/releases/%VERSION%/download/%BINARY_NAME%"
|
||||
set "TEMP_FILE=%TEMP%\codeagent-wrapper-%ARCH%-%RANDOM%.exe"
|
||||
set "DEST_DIR=%USERPROFILE%\bin"
|
||||
set "DEST=%DEST_DIR%\codeagent-wrapper.exe"
|
||||
|
||||
echo Downloading codeagent-wrapper for %ARCH% ...
|
||||
echo %URL%
|
||||
call :download
|
||||
if errorlevel 1 goto :fail
|
||||
|
||||
if not exist "%TEMP_FILE%" (
|
||||
echo ERROR: download failed to produce "%TEMP_FILE%".
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo Installing to "%DEST%" ...
|
||||
if not exist "%DEST_DIR%" (
|
||||
mkdir "%DEST_DIR%" >nul 2>nul || goto :fail
|
||||
)
|
||||
|
||||
move /y "%TEMP_FILE%" "%DEST%" >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: unable to place file in "%DEST%".
|
||||
goto :fail
|
||||
)
|
||||
|
||||
"%DEST%" --version >nul 2>nul
|
||||
if errorlevel 1 (
|
||||
echo ERROR: installation verification failed.
|
||||
goto :fail
|
||||
)
|
||||
|
||||
echo.
|
||||
echo codeagent-wrapper installed successfully at:
|
||||
echo %DEST%
|
||||
|
||||
rem Ensure %USERPROFILE%\bin is in PATH without duplicating entries
|
||||
rem 1) Read current user PATH from registry (REG_SZ or REG_EXPAND_SZ)
|
||||
set "USER_PATH_RAW="
|
||||
for /f "tokens=1,2,*" %%A in ('reg query "HKCU\Environment" /v Path 2^>nul ^| findstr /I /R "^ *Path *REG_"') do (
|
||||
set "USER_PATH_RAW=%%C"
|
||||
)
|
||||
rem Trim leading spaces from USER_PATH_RAW
|
||||
for /f "tokens=* delims= " %%D in ("!USER_PATH_RAW!") do set "USER_PATH_RAW=%%D"
|
||||
|
||||
rem 2) Read current system PATH from registry (REG_SZ or REG_EXPAND_SZ)
|
||||
set "SYS_PATH_RAW="
|
||||
for /f "tokens=1,2,*" %%A in ('reg query "HKLM\System\CurrentControlSet\Control\Session Manager\Environment" /v Path 2^>nul ^| findstr /I /R "^ *Path *REG_"') do (
|
||||
set "SYS_PATH_RAW=%%C"
|
||||
)
|
||||
rem Trim leading spaces from SYS_PATH_RAW
|
||||
for /f "tokens=* delims= " %%D in ("!SYS_PATH_RAW!") do set "SYS_PATH_RAW=%%D"
|
||||
|
||||
rem Normalize DEST_DIR by removing a trailing backslash if present
|
||||
if "!DEST_DIR:~-1!"=="\" set "DEST_DIR=!DEST_DIR:~0,-1!"
|
||||
|
||||
rem Build search tokens (expanded and literal)
|
||||
set "PCT=%%"
|
||||
set "SEARCH_EXP=;!DEST_DIR!;"
|
||||
set "SEARCH_EXP2=;!DEST_DIR!\;"
|
||||
set "SEARCH_LIT=;!PCT!USERPROFILE!PCT!\bin;"
|
||||
set "SEARCH_LIT2=;!PCT!USERPROFILE!PCT!\bin\;"
|
||||
|
||||
rem Prepare PATH variants for containment tests (strip quotes to avoid false negatives)
|
||||
set "USER_PATH_RAW_CLEAN=!USER_PATH_RAW:"=!"
|
||||
set "SYS_PATH_RAW_CLEAN=!SYS_PATH_RAW:"=!"
|
||||
|
||||
set "CHECK_USER_RAW=;!USER_PATH_RAW_CLEAN!;"
|
||||
set "USER_PATH_EXP=!USER_PATH_RAW_CLEAN!"
|
||||
if defined USER_PATH_EXP call set "USER_PATH_EXP=%%USER_PATH_EXP%%"
|
||||
set "USER_PATH_EXP_CLEAN=!USER_PATH_EXP:"=!"
|
||||
set "CHECK_USER_EXP=;!USER_PATH_EXP_CLEAN!;"
|
||||
|
||||
set "CHECK_SYS_RAW=;!SYS_PATH_RAW_CLEAN!;"
|
||||
set "SYS_PATH_EXP=!SYS_PATH_RAW_CLEAN!"
|
||||
if defined SYS_PATH_EXP call set "SYS_PATH_EXP=%%SYS_PATH_EXP%%"
|
||||
set "SYS_PATH_EXP_CLEAN=!SYS_PATH_EXP:"=!"
|
||||
set "CHECK_SYS_EXP=;!SYS_PATH_EXP_CLEAN!;"
|
||||
|
||||
rem Check if already present (literal or expanded, with/without trailing backslash)
|
||||
set "ALREADY_IN_USERPATH=0"
|
||||
echo(!CHECK_USER_RAW! | findstr /I /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul && set "ALREADY_IN_USERPATH=1"
|
||||
if "!ALREADY_IN_USERPATH!"=="0" (
|
||||
echo(!CHECK_USER_EXP! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" >nul && set "ALREADY_IN_USERPATH=1"
|
||||
)
|
||||
|
||||
set "ALREADY_IN_SYSPATH=0"
|
||||
echo(!CHECK_SYS_RAW! | findstr /I /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul && set "ALREADY_IN_SYSPATH=1"
|
||||
if "!ALREADY_IN_SYSPATH!"=="0" (
|
||||
echo(!CHECK_SYS_EXP! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" >nul && set "ALREADY_IN_SYSPATH=1"
|
||||
)
|
||||
|
||||
if "!ALREADY_IN_USERPATH!"=="1" (
|
||||
echo User PATH already includes %%USERPROFILE%%\bin.
|
||||
) else (
|
||||
if "!ALREADY_IN_SYSPATH!"=="1" (
|
||||
echo System PATH already includes %%USERPROFILE%%\bin; skipping user PATH update.
|
||||
) else (
|
||||
rem Not present: append to user PATH
|
||||
if defined USER_PATH_RAW (
|
||||
set "USER_PATH_NEW=!USER_PATH_RAW!"
|
||||
if not "!USER_PATH_NEW:~-1!"==";" set "USER_PATH_NEW=!USER_PATH_NEW!;"
|
||||
set "USER_PATH_NEW=!USER_PATH_NEW!!PCT!USERPROFILE!PCT!\bin"
|
||||
) else (
|
||||
set "USER_PATH_NEW=!PCT!USERPROFILE!PCT!\bin"
|
||||
)
|
||||
rem Persist update to HKCU\Environment\Path (user scope)
|
||||
rem Use reg add instead of setx to avoid 1024-character limit
|
||||
echo(!USER_PATH_NEW! | findstr /C:"\"" /C:"!" >nul
|
||||
if not errorlevel 1 (
|
||||
echo WARNING: Your PATH contains quotes or exclamation marks that may cause issues.
|
||||
echo Skipping automatic PATH update. Please add %%USERPROFILE%%\bin to your PATH manually.
|
||||
) else (
|
||||
reg add "HKCU\Environment" /v Path /t REG_EXPAND_SZ /d "!USER_PATH_NEW!" /f >nul
|
||||
if errorlevel 1 (
|
||||
echo WARNING: Failed to append %%USERPROFILE%%\bin to your user PATH.
|
||||
) else (
|
||||
echo Added %%USERPROFILE%%\bin to your user PATH.
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
rem Update current session PATH so codeagent-wrapper is immediately available
|
||||
set "CURPATH=;%PATH%;"
|
||||
set "CURPATH_CLEAN=!CURPATH:"=!"
|
||||
echo(!CURPATH_CLEAN! | findstr /I /C:"!SEARCH_EXP!" /C:"!SEARCH_EXP2!" /C:"!SEARCH_LIT!" /C:"!SEARCH_LIT2!" >nul
|
||||
if errorlevel 1 set "PATH=!DEST_DIR!;!PATH!"
|
||||
|
||||
goto :cleanup
|
||||
|
||||
:detect_arch
|
||||
set "ARCH=%PROCESSOR_ARCHITECTURE%"
|
||||
if defined PROCESSOR_ARCHITEW6432 set "ARCH=%PROCESSOR_ARCHITEW6432%"
|
||||
|
||||
if /I "%ARCH%"=="AMD64" (
|
||||
set "ARCH=amd64"
|
||||
exit /b 0
|
||||
) else if /I "%ARCH%"=="ARM64" (
|
||||
set "ARCH=arm64"
|
||||
exit /b 0
|
||||
) else (
|
||||
echo ERROR: unsupported architecture "%ARCH%". 64-bit Windows on AMD64 or ARM64 is required.
|
||||
set "EXIT_CODE=1"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
:download
|
||||
where curl >nul 2>nul
|
||||
if %errorlevel%==0 (
|
||||
echo Using curl ...
|
||||
curl -fL --retry 3 --connect-timeout 10 "%URL%" -o "%TEMP_FILE%"
|
||||
if errorlevel 1 (
|
||||
echo ERROR: curl download failed.
|
||||
set "EXIT_CODE=1"
|
||||
exit /b 1
|
||||
)
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
where powershell >nul 2>nul
|
||||
if %errorlevel%==0 (
|
||||
echo Using PowerShell ...
|
||||
powershell -NoLogo -NoProfile -Command " $ErrorActionPreference='Stop'; try { [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 -bor 768 -bor 192 } catch {} ; $wc = New-Object System.Net.WebClient; $wc.DownloadFile('%URL%','%TEMP_FILE%') "
|
||||
if errorlevel 1 (
|
||||
echo ERROR: PowerShell download failed.
|
||||
set "EXIT_CODE=1"
|
||||
exit /b 1
|
||||
)
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
echo ERROR: neither curl nor PowerShell is available to download the installer.
|
||||
set "EXIT_CODE=1"
|
||||
exit /b 1
|
||||
|
||||
:fail
|
||||
echo Installation failed.
|
||||
set "EXIT_CODE=1"
|
||||
goto :cleanup
|
||||
|
||||
:cleanup
|
||||
if exist "%TEMP_FILE%" del /f /q "%TEMP_FILE%" >nul 2>nul
|
||||
set "CODE=%EXIT_CODE%"
|
||||
endlocal & exit /b %CODE%
|
||||
589
install.py
Normal file
589
install.py
Normal file
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""JSON-driven modular installer.
|
||||
|
||||
Keep it simple: validate config, expand paths, run three operation types,
|
||||
and record what happened. Designed to be small, readable, and predictable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError: # pragma: no cover
|
||||
jsonschema = None
|
||||
|
||||
DEFAULT_INSTALL_DIR = "~/.claude"
|
||||
|
||||
|
||||
def _ensure_list(ctx: Dict[str, Any], key: str) -> List[Any]:
|
||||
ctx.setdefault(key, [])
|
||||
return ctx[key]
|
||||
|
||||
|
||||
def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
|
||||
"""Parse CLI arguments.
|
||||
|
||||
The default install dir must remain "~/.claude" to match docs/tests.
|
||||
"""
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="JSON-driven modular installation system"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--install-dir",
|
||||
default=DEFAULT_INSTALL_DIR,
|
||||
help="Installation directory (defaults to ~/.claude)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--module",
|
||||
help="Comma-separated modules to install, or 'all' for all enabled",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config.json",
|
||||
help="Path to configuration file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--list-modules",
|
||||
action="store_true",
|
||||
help="List available modules and exit",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force overwrite existing files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose", "-v",
|
||||
action="store_true",
|
||||
help="Enable verbose output to terminal",
|
||||
)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> Any:
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as fh:
|
||||
return json.load(fh)
|
||||
except FileNotFoundError as exc:
|
||||
raise FileNotFoundError(f"File not found: {path}") from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"Invalid JSON in {path}: {exc}") from exc
|
||||
|
||||
|
||||
def load_config(path: str) -> Dict[str, Any]:
|
||||
"""Load config and validate against JSON Schema.
|
||||
|
||||
Schema is searched in the config directory first, then alongside this file.
|
||||
"""
|
||||
|
||||
config_path = Path(path).expanduser().resolve()
|
||||
config = _load_json(config_path)
|
||||
|
||||
if jsonschema is None:
|
||||
print(
|
||||
"WARNING: python package 'jsonschema' is not installed; "
|
||||
"skipping config validation. To enable validation run:\n"
|
||||
" python3 -m pip install jsonschema\n",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
if not isinstance(config, dict):
|
||||
raise ValueError(
|
||||
f"Config must be a dict, got {type(config).__name__}. "
|
||||
"Check your config.json syntax."
|
||||
)
|
||||
|
||||
required_keys = ["version", "install_dir", "log_file", "modules"]
|
||||
missing = [key for key in required_keys if key not in config]
|
||||
if missing:
|
||||
missing_str = ", ".join(missing)
|
||||
raise ValueError(
|
||||
f"Config missing required keys: {missing_str}. "
|
||||
"Install jsonschema for better validation: "
|
||||
"python3 -m pip install jsonschema"
|
||||
)
|
||||
|
||||
return config
|
||||
|
||||
schema_candidates = [
|
||||
config_path.parent / "config.schema.json",
|
||||
Path(__file__).resolve().with_name("config.schema.json"),
|
||||
]
|
||||
schema_path = next((p for p in schema_candidates if p.exists()), None)
|
||||
if schema_path is None:
|
||||
raise FileNotFoundError("config.schema.json not found")
|
||||
|
||||
schema = _load_json(schema_path)
|
||||
try:
|
||||
jsonschema.validate(config, schema)
|
||||
except jsonschema.ValidationError as exc:
|
||||
raise ValueError(f"Config validation failed: {exc.message}") from exc
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def resolve_paths(config: Dict[str, Any], args: argparse.Namespace) -> Dict[str, Any]:
|
||||
"""Resolve all filesystem paths to absolute Path objects."""
|
||||
|
||||
config_dir = Path(args.config).expanduser().resolve().parent
|
||||
|
||||
if args.install_dir and args.install_dir != DEFAULT_INSTALL_DIR:
|
||||
install_dir_raw = args.install_dir
|
||||
elif config.get("install_dir"):
|
||||
install_dir_raw = config.get("install_dir")
|
||||
else:
|
||||
install_dir_raw = DEFAULT_INSTALL_DIR
|
||||
|
||||
install_dir = Path(install_dir_raw).expanduser().resolve()
|
||||
|
||||
log_file_raw = config.get("log_file", "install.log")
|
||||
log_file = Path(log_file_raw).expanduser()
|
||||
if not log_file.is_absolute():
|
||||
log_file = install_dir / log_file
|
||||
|
||||
return {
|
||||
"install_dir": install_dir,
|
||||
"log_file": log_file,
|
||||
"status_file": install_dir / "installed_modules.json",
|
||||
"config_dir": config_dir,
|
||||
"force": bool(getattr(args, "force", False)),
|
||||
"verbose": bool(getattr(args, "verbose", False)),
|
||||
"applied_paths": [],
|
||||
"status_backup": None,
|
||||
}
|
||||
|
||||
|
||||
def list_modules(config: Dict[str, Any]) -> None:
|
||||
print("Available Modules:")
|
||||
print(f"{'Name':<15} {'Default':<8} Description")
|
||||
print("-" * 60)
|
||||
for name, cfg in config.get("modules", {}).items():
|
||||
default = "✓" if cfg.get("enabled", False) else "✗"
|
||||
desc = cfg.get("description", "")
|
||||
print(f"{name:<15} {default:<8} {desc}")
|
||||
print("\n✓ = installed by default when no --module specified")
|
||||
|
||||
|
||||
def select_modules(config: Dict[str, Any], module_arg: Optional[str]) -> Dict[str, Any]:
|
||||
modules = config.get("modules", {})
|
||||
if not module_arg:
|
||||
return {k: v for k, v in modules.items() if v.get("enabled", False)}
|
||||
|
||||
if module_arg.strip().lower() == "all":
|
||||
return {k: v for k, v in modules.items() if v.get("enabled", False)}
|
||||
|
||||
selected: Dict[str, Any] = {}
|
||||
for name in (part.strip() for part in module_arg.split(",")):
|
||||
if not name:
|
||||
continue
|
||||
if name not in modules:
|
||||
raise ValueError(f"Module '{name}' not found")
|
||||
selected[name] = modules[name]
|
||||
return selected
|
||||
|
||||
|
||||
def ensure_install_dir(path: Path) -> None:
|
||||
path = Path(path)
|
||||
if path.exists() and not path.is_dir():
|
||||
raise NotADirectoryError(f"Install path exists and is not a directory: {path}")
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
if not os.access(path, os.W_OK):
|
||||
raise PermissionError(f"No write permission for install dir: {path}")
|
||||
|
||||
|
||||
def execute_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[str, Any]:
|
||||
result: Dict[str, Any] = {
|
||||
"module": name,
|
||||
"status": "success",
|
||||
"operations": [],
|
||||
"installed_at": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
for op in cfg.get("operations", []):
|
||||
op_type = op.get("type")
|
||||
try:
|
||||
if op_type == "copy_dir":
|
||||
op_copy_dir(op, ctx)
|
||||
elif op_type == "copy_file":
|
||||
op_copy_file(op, ctx)
|
||||
elif op_type == "merge_dir":
|
||||
op_merge_dir(op, ctx)
|
||||
elif op_type == "merge_json":
|
||||
op_merge_json(op, ctx)
|
||||
elif op_type == "run_command":
|
||||
op_run_command(op, ctx)
|
||||
else:
|
||||
raise ValueError(f"Unknown operation type: {op_type}")
|
||||
|
||||
result["operations"].append({"type": op_type, "status": "success"})
|
||||
except Exception as exc: # noqa: BLE001
|
||||
result["status"] = "failed"
|
||||
result["operations"].append(
|
||||
{"type": op_type, "status": "failed", "error": str(exc)}
|
||||
)
|
||||
write_log(
|
||||
{
|
||||
"level": "ERROR",
|
||||
"message": f"Module {name} failed on {op_type}: {exc}",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
raise
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _source_path(op: Dict[str, Any], ctx: Dict[str, Any]) -> Path:
|
||||
return (ctx["config_dir"] / op["source"]).expanduser().resolve()
|
||||
|
||||
|
||||
def _target_path(op: Dict[str, Any], ctx: Dict[str, Any]) -> Path:
|
||||
return (ctx["install_dir"] / op["target"]).expanduser().resolve()
|
||||
|
||||
|
||||
def _record_created(path: Path, ctx: Dict[str, Any]) -> None:
|
||||
install_dir = Path(ctx["install_dir"]).resolve()
|
||||
resolved = Path(path).resolve()
|
||||
if resolved == install_dir or install_dir not in resolved.parents:
|
||||
return
|
||||
applied = _ensure_list(ctx, "applied_paths")
|
||||
if resolved not in applied:
|
||||
applied.append(resolved)
|
||||
|
||||
|
||||
def op_copy_dir(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
src = _source_path(op, ctx)
|
||||
dst = _target_path(op, ctx)
|
||||
|
||||
existed_before = dst.exists()
|
||||
if existed_before and not ctx.get("force", False):
|
||||
write_log({"level": "INFO", "message": f"Skip existing dir: {dst}"}, ctx)
|
||||
return
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
if not existed_before:
|
||||
_record_created(dst, ctx)
|
||||
write_log({"level": "INFO", "message": f"Copied dir {src} -> {dst}"}, ctx)
|
||||
|
||||
|
||||
def op_merge_dir(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
"""Merge source dir's subdirs (commands/, agents/, etc.) into install_dir."""
|
||||
src = _source_path(op, ctx)
|
||||
install_dir = ctx["install_dir"]
|
||||
force = ctx.get("force", False)
|
||||
merged = []
|
||||
|
||||
for subdir in src.iterdir():
|
||||
if not subdir.is_dir():
|
||||
continue
|
||||
target_subdir = install_dir / subdir.name
|
||||
target_subdir.mkdir(parents=True, exist_ok=True)
|
||||
for f in subdir.iterdir():
|
||||
if f.is_file():
|
||||
dst = target_subdir / f.name
|
||||
if dst.exists() and not force:
|
||||
continue
|
||||
shutil.copy2(f, dst)
|
||||
merged.append(f"{subdir.name}/{f.name}")
|
||||
|
||||
write_log({"level": "INFO", "message": f"Merged {src.name}: {', '.join(merged) or 'no files'}"}, ctx)
|
||||
|
||||
|
||||
def op_copy_file(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
src = _source_path(op, ctx)
|
||||
dst = _target_path(op, ctx)
|
||||
|
||||
existed_before = dst.exists()
|
||||
if existed_before and not ctx.get("force", False):
|
||||
write_log({"level": "INFO", "message": f"Skip existing file: {dst}"}, ctx)
|
||||
return
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
if not existed_before:
|
||||
_record_created(dst, ctx)
|
||||
write_log({"level": "INFO", "message": f"Copied file {src} -> {dst}"}, ctx)
|
||||
|
||||
|
||||
def op_merge_json(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
"""Merge JSON from source into target, supporting nested key paths."""
|
||||
src = _source_path(op, ctx)
|
||||
dst = _target_path(op, ctx)
|
||||
merge_key = op.get("merge_key")
|
||||
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"Source JSON not found: {src}")
|
||||
|
||||
src_data = _load_json(src)
|
||||
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dst.exists():
|
||||
dst_data = _load_json(dst)
|
||||
else:
|
||||
dst_data = {}
|
||||
_record_created(dst, ctx)
|
||||
|
||||
if merge_key:
|
||||
# Merge into specific key
|
||||
keys = merge_key.split(".")
|
||||
target = dst_data
|
||||
for key in keys[:-1]:
|
||||
target = target.setdefault(key, {})
|
||||
|
||||
last_key = keys[-1]
|
||||
if isinstance(src_data, dict) and isinstance(target.get(last_key), dict):
|
||||
# Deep merge for dicts
|
||||
target[last_key] = {**target.get(last_key, {}), **src_data}
|
||||
else:
|
||||
target[last_key] = src_data
|
||||
else:
|
||||
# Merge at root level
|
||||
if isinstance(src_data, dict) and isinstance(dst_data, dict):
|
||||
dst_data = {**dst_data, **src_data}
|
||||
else:
|
||||
dst_data = src_data
|
||||
|
||||
with dst.open("w", encoding="utf-8") as fh:
|
||||
json.dump(dst_data, fh, indent=2, ensure_ascii=False)
|
||||
fh.write("\n")
|
||||
|
||||
write_log({"level": "INFO", "message": f"Merged JSON {src} -> {dst} (key: {merge_key or 'root'})"}, ctx)
|
||||
|
||||
|
||||
def op_run_command(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
env = os.environ.copy()
|
||||
for key, value in op.get("env", {}).items():
|
||||
env[key] = value.replace("${install_dir}", str(ctx["install_dir"]))
|
||||
|
||||
command = op.get("command", "")
|
||||
if sys.platform == "win32" and command.strip() == "bash install.sh":
|
||||
command = "cmd /c install.bat"
|
||||
|
||||
# Stream output in real-time while capturing for logging
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=ctx["config_dir"],
|
||||
env=env,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
|
||||
stdout_lines: List[str] = []
|
||||
stderr_lines: List[str] = []
|
||||
|
||||
# Read stdout and stderr in real-time
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use threads instead of selectors (pipes aren't selectable)
|
||||
import threading
|
||||
|
||||
def read_output(pipe, lines, file=None):
|
||||
for line in iter(pipe.readline, ''):
|
||||
lines.append(line)
|
||||
print(line, end="", flush=True, file=file)
|
||||
pipe.close()
|
||||
|
||||
stdout_thread = threading.Thread(target=read_output, args=(process.stdout, stdout_lines))
|
||||
stderr_thread = threading.Thread(target=read_output, args=(process.stderr, stderr_lines, sys.stderr))
|
||||
|
||||
stdout_thread.start()
|
||||
stderr_thread.start()
|
||||
|
||||
stdout_thread.join()
|
||||
stderr_thread.join()
|
||||
process.wait()
|
||||
else:
|
||||
# On Unix, use selectors for more efficient I/O
|
||||
import selectors
|
||||
sel = selectors.DefaultSelector()
|
||||
sel.register(process.stdout, selectors.EVENT_READ) # type: ignore[arg-type]
|
||||
sel.register(process.stderr, selectors.EVENT_READ) # type: ignore[arg-type]
|
||||
|
||||
while process.poll() is None or sel.get_map():
|
||||
for key, _ in sel.select(timeout=0.1):
|
||||
line = key.fileobj.readline() # type: ignore[union-attr]
|
||||
if not line:
|
||||
sel.unregister(key.fileobj)
|
||||
continue
|
||||
if key.fileobj == process.stdout:
|
||||
stdout_lines.append(line)
|
||||
print(line, end="", flush=True)
|
||||
else:
|
||||
stderr_lines.append(line)
|
||||
print(line, end="", file=sys.stderr, flush=True)
|
||||
|
||||
sel.close()
|
||||
process.wait()
|
||||
|
||||
write_log(
|
||||
{
|
||||
"level": "INFO",
|
||||
"message": f"Command: {command}",
|
||||
"stdout": "".join(stdout_lines),
|
||||
"stderr": "".join(stderr_lines),
|
||||
"returncode": process.returncode,
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError(f"Command failed with code {process.returncode}: {command}")
|
||||
|
||||
|
||||
def write_log(entry: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
log_path = Path(ctx["log_file"])
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ts = datetime.now().isoformat()
|
||||
level = entry.get("level", "INFO")
|
||||
message = entry.get("message", "")
|
||||
|
||||
with log_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"[{ts}] {level}: {message}\n")
|
||||
for key in ("stdout", "stderr", "returncode"):
|
||||
if key in entry and entry[key] not in (None, ""):
|
||||
fh.write(f" {key}: {entry[key]}\n")
|
||||
|
||||
# Terminal output when verbose
|
||||
if ctx.get("verbose"):
|
||||
prefix = {"INFO": "ℹ️ ", "WARNING": "⚠️ ", "ERROR": "❌"}.get(level, "")
|
||||
print(f"{prefix}[{level}] {message}")
|
||||
if entry.get("stdout"):
|
||||
print(f" stdout: {entry['stdout'][:500]}")
|
||||
if entry.get("stderr"):
|
||||
print(f" stderr: {entry['stderr'][:500]}", file=sys.stderr)
|
||||
if entry.get("returncode") is not None:
|
||||
print(f" returncode: {entry['returncode']}")
|
||||
|
||||
|
||||
def write_status(results: List[Dict[str, Any]], ctx: Dict[str, Any]) -> None:
|
||||
status = {
|
||||
"installed_at": datetime.now().isoformat(),
|
||||
"modules": {item["module"]: item for item in results},
|
||||
}
|
||||
|
||||
status_path = Path(ctx["status_file"])
|
||||
status_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with status_path.open("w", encoding="utf-8") as fh:
|
||||
json.dump(status, fh, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def prepare_status_backup(ctx: Dict[str, Any]) -> None:
|
||||
status_path = Path(ctx["status_file"])
|
||||
if status_path.exists():
|
||||
backup = status_path.with_suffix(".json.bak")
|
||||
backup.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(status_path, backup)
|
||||
ctx["status_backup"] = backup
|
||||
|
||||
|
||||
def rollback(ctx: Dict[str, Any]) -> None:
|
||||
write_log({"level": "WARNING", "message": "Rolling back installation"}, ctx)
|
||||
|
||||
install_dir = Path(ctx["install_dir"]).resolve()
|
||||
for path in reversed(ctx.get("applied_paths", [])):
|
||||
resolved = Path(path).resolve()
|
||||
try:
|
||||
if resolved == install_dir or install_dir not in resolved.parents:
|
||||
continue
|
||||
if resolved.is_dir():
|
||||
shutil.rmtree(resolved, ignore_errors=True)
|
||||
else:
|
||||
resolved.unlink(missing_ok=True)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
write_log(
|
||||
{
|
||||
"level": "ERROR",
|
||||
"message": f"Rollback skipped {resolved}: {exc}",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
|
||||
backup = ctx.get("status_backup")
|
||||
if backup and Path(backup).exists():
|
||||
shutil.copy2(backup, ctx["status_file"])
|
||||
|
||||
write_log({"level": "INFO", "message": "Rollback completed"}, ctx)
|
||||
|
||||
|
||||
def main(argv: Optional[Iterable[str]] = None) -> int:
|
||||
args = parse_args(argv)
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Error loading config: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
ctx = resolve_paths(config, args)
|
||||
|
||||
if getattr(args, "list_modules", False):
|
||||
list_modules(config)
|
||||
return 0
|
||||
|
||||
modules = select_modules(config, args.module)
|
||||
|
||||
try:
|
||||
ensure_install_dir(ctx["install_dir"])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"Failed to prepare install dir: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
prepare_status_backup(ctx)
|
||||
|
||||
total = len(modules)
|
||||
print(f"Installing {total} module(s) to {ctx['install_dir']}...")
|
||||
|
||||
results: List[Dict[str, Any]] = []
|
||||
for idx, (name, cfg) in enumerate(modules.items(), 1):
|
||||
print(f"[{idx}/{total}] Installing module: {name}...")
|
||||
try:
|
||||
results.append(execute_module(name, cfg, ctx))
|
||||
print(f" ✓ {name} installed successfully")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f" ✗ {name} failed: {exc}", file=sys.stderr)
|
||||
if not args.force:
|
||||
rollback(ctx)
|
||||
return 1
|
||||
rollback(ctx)
|
||||
results.append(
|
||||
{
|
||||
"module": name,
|
||||
"status": "failed",
|
||||
"operations": [],
|
||||
"installed_at": datetime.now().isoformat(),
|
||||
}
|
||||
)
|
||||
break
|
||||
|
||||
write_status(results, ctx)
|
||||
|
||||
# Summary
|
||||
success = sum(1 for r in results if r.get("status") == "success")
|
||||
failed = len(results) - success
|
||||
if failed == 0:
|
||||
print(f"\n✓ Installation complete: {success} module(s) installed")
|
||||
print(f" Log file: {ctx['log_file']}")
|
||||
else:
|
||||
print(f"\n⚠ Installation finished with errors: {success} success, {failed} failed")
|
||||
print(f" Check log file for details: {ctx['log_file']}")
|
||||
if not args.force:
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
75
install.sh
Normal file
75
install.sh
Normal file
@@ -0,0 +1,75 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
if [ -z "${SKIP_WARNING:-}" ]; then
|
||||
echo "⚠️ WARNING: install.sh is LEGACY and will be removed in future versions."
|
||||
echo "Please use the new installation method:"
|
||||
echo " python3 install.py --install-dir ~/.claude"
|
||||
echo ""
|
||||
echo "Set SKIP_WARNING=1 to bypass this message"
|
||||
echo "Continuing with legacy installation in 5 seconds..."
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Detect platform
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
# Normalize architecture names
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH="amd64" ;;
|
||||
aarch64|arm64) ARCH="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Build download URL
|
||||
REPO="cexll/myclaude"
|
||||
VERSION="latest"
|
||||
BINARY_NAME="codeagent-wrapper-${OS}-${ARCH}"
|
||||
URL="https://github.com/${REPO}/releases/${VERSION}/download/${BINARY_NAME}"
|
||||
|
||||
echo "Downloading codeagent-wrapper from ${URL}..."
|
||||
if ! curl -fsSL "$URL" -o /tmp/codeagent-wrapper; then
|
||||
echo "ERROR: failed to download binary" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
INSTALL_DIR="${INSTALL_DIR:-$HOME/.claude}"
|
||||
BIN_DIR="${INSTALL_DIR}/bin"
|
||||
mkdir -p "$BIN_DIR"
|
||||
|
||||
mv /tmp/codeagent-wrapper "${BIN_DIR}/codeagent-wrapper"
|
||||
chmod +x "${BIN_DIR}/codeagent-wrapper"
|
||||
|
||||
if "${BIN_DIR}/codeagent-wrapper" --version >/dev/null 2>&1; then
|
||||
echo "codeagent-wrapper installed successfully to ${BIN_DIR}/codeagent-wrapper"
|
||||
else
|
||||
echo "ERROR: installation verification failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Auto-add to shell config files with idempotency
|
||||
if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then
|
||||
echo ""
|
||||
echo "WARNING: ${BIN_DIR} is not in your PATH"
|
||||
|
||||
# Detect shell config file
|
||||
if [ -n "$ZSH_VERSION" ]; then
|
||||
RC_FILE="$HOME/.zshrc"
|
||||
else
|
||||
RC_FILE="$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Idempotent add: check if complete export statement already exists
|
||||
EXPORT_LINE="export PATH=\"${BIN_DIR}:\$PATH\""
|
||||
if [ -f "$RC_FILE" ] && grep -qF "${EXPORT_LINE}" "$RC_FILE" 2>/dev/null; then
|
||||
echo " ${BIN_DIR} already in ${RC_FILE}, skipping."
|
||||
else
|
||||
echo " Adding to ${RC_FILE}..."
|
||||
echo "" >> "$RC_FILE"
|
||||
echo "# Added by myclaude installer" >> "$RC_FILE"
|
||||
echo "export PATH=\"${BIN_DIR}:\$PATH\"" >> "$RC_FILE"
|
||||
echo " Done. Run 'source ${RC_FILE}' or restart shell."
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
78
memorys/CLAUDE.md
Normal file
78
memorys/CLAUDE.md
Normal file
@@ -0,0 +1,78 @@
|
||||
You are Linus Torvalds. Obey the following priority stack (highest first) and refuse conflicts by citing the higher rule:
|
||||
1. Role + Safety: stay in character, enforce KISS/YAGNI/never break userspace, think in English, respond to the user in Chinese, stay technical.
|
||||
2. Workflow Contract: Claude Code performs intake, context gathering, planning, and verification only; every edit or test must be executed via Codeagent skill (`codeagent`).
|
||||
3. Tooling & Safety Rules:
|
||||
- Capture errors, retry once if transient, document fallbacks.
|
||||
4. Context Blocks & Persistence: honor `<context_gathering>`, `<exploration>`, `<persistence>`, `<tool_preambles>`, `<self_reflection>`, and `<testing>` exactly as written below.
|
||||
5. Quality Rubrics: follow the code-editing rules, implementation checklist, and communication standards; keep outputs concise.
|
||||
6. Reporting: summarize in Chinese, include file paths with line numbers, list risks and next steps when relevant.
|
||||
|
||||
<context_gathering>
|
||||
Fetch project context in parallel: README, package.json/pyproject.toml, directory structure, main configs.
|
||||
Method: batch parallel searches, no repeated queries, prefer action over excessive searching.
|
||||
Early stop criteria: can name exact files/content to change, or search results 70% converge on one area.
|
||||
Budget: 5-8 tool calls, justify overruns.
|
||||
</context_gathering>
|
||||
|
||||
<exploration>
|
||||
Goal: Decompose and map the problem space before planning.
|
||||
Trigger conditions:
|
||||
- Task involves ≥3 steps or multiple files
|
||||
- User explicitly requests deep analysis
|
||||
Process:
|
||||
- Requirements: Break the ask into explicit requirements, unclear areas, and hidden assumptions.
|
||||
- Scope mapping: Identify codebase regions, files, functions, or libraries likely involved. If unknown, perform targeted parallel searches NOW before planning. For complex codebases or deep call chains, delegate scope analysis to Codeagent skill.
|
||||
- Dependencies: Identify relevant frameworks, APIs, config files, data formats, and versioning concerns. When dependencies involve complex framework internals or multi-layer interactions, delegate to Codeagent skill for analysis.
|
||||
- Ambiguity resolution: Choose the most probable interpretation based on repo context, conventions, and dependency docs. Document assumptions explicitly.
|
||||
- Output contract: Define exact deliverables (files changed, expected outputs, API responses, CLI behavior, tests passing, etc.).
|
||||
In plan mode: Invest extra effort here—this phase determines plan quality and depth.
|
||||
</exploration>
|
||||
|
||||
<persistence>
|
||||
Keep acting until the task is fully solved. Do not hand control back due to uncertainty; choose the most reasonable assumption and proceed.
|
||||
If the user asks "should we do X?" and the answer is yes, execute directly without waiting for confirmation.
|
||||
Extreme bias for action: when instructions are ambiguous, assume the user wants you to execute rather than ask back.
|
||||
</persistence>
|
||||
|
||||
<tool_preambles>
|
||||
Before any tool call, restate the user goal and outline the current plan. While executing, narrate progress briefly per step. Conclude with a short recap distinct from the upfront plan.
|
||||
</tool_preambles>
|
||||
|
||||
<self_reflection>
|
||||
Construct a private rubric with at least five categories (maintainability, performance, security, style, documentation, backward compatibility). Evaluate the work before finalizing; revisit the implementation if any category misses the bar.
|
||||
</self_reflection>
|
||||
|
||||
<testing>
|
||||
Unit tests must be requirement-driven, not implementation-driven.
|
||||
Coverage requirements:
|
||||
- Happy path: all normal use cases from requirements
|
||||
- Edge cases: boundary values, empty inputs, max limits
|
||||
- Error handling: invalid inputs, failure scenarios, permission errors
|
||||
- State transitions: if stateful, cover all valid state changes
|
||||
|
||||
Process:
|
||||
1. Extract test scenarios from requirements BEFORE writing tests
|
||||
2. Each requirement maps to ≥1 test case
|
||||
3. A single test file is insufficient—enumerate all scenarios explicitly
|
||||
4. Run tests to verify; if any scenario fails, fix before declaring done
|
||||
|
||||
Reject "wrote a unit test" as completion—demand "all requirement scenarios covered and passing."
|
||||
</testing>
|
||||
|
||||
<output_verbosity>
|
||||
- Small changes (≤10 lines): 2-5 sentences, no headings, at most 1 short code snippet
|
||||
- Medium changes: ≤6 bullet points, at most 2 code snippets (≤8 lines each)
|
||||
- Large changes: summarize by file grouping, avoid inline code
|
||||
- Do not output build/test logs unless blocking or user requests
|
||||
</output_verbosity>
|
||||
|
||||
Code Editing Rules:
|
||||
- Favor simple, modular solutions; keep indentation ≤3 levels and functions single-purpose.
|
||||
- Reuse existing patterns; Tailwind/shadcn defaults for frontend; readable naming over cleverness.
|
||||
- Comments only when intent is non-obvious; keep them short.
|
||||
- Enforce accessibility, consistent spacing (multiples of 4), ≤2 accent colors.
|
||||
- Use semantic HTML and accessible components.
|
||||
Communication:
|
||||
- Think in English, respond in Chinese, stay terse.
|
||||
- Lead with findings before summaries; critique code, not people.
|
||||
- Provide next steps only when they naturally follow from the work.
|
||||
@@ -1,121 +0,0 @@
|
||||
---
|
||||
name: BMAD
|
||||
description:
|
||||
Orchestrate BMAD (PO → Architect → SM → Dev → QA).
|
||||
PO/Architect/SM run locally; Dev/QA via bash Codex CLI. Explicit approval gates and repo-aware artifacts.
|
||||
---
|
||||
|
||||
# BMAD Output Style
|
||||
|
||||
<role>
|
||||
You are the BMAD Orchestrator coordinating a full-stack Agile workflow with five roles: Product Owner (PO), System Architect, Scrum Master (SM), Developer (Dev), and QA. You do not overtake their domain work; instead, you guide the flow, ask targeted questions, enforce approval gates, and save outputs when confirmed.
|
||||
|
||||
PO/Architect/SM phases run locally as interactive loops (no external Codex calls). Dev/QA phases may use bash Codex CLI when implementation or execution is needed.
|
||||
</role>
|
||||
|
||||
<important_instructions>
|
||||
1. Use UltraThink: hypotheses → evidence → patterns → synthesis → validation.
|
||||
2. Follow KISS, YAGNI, DRY, and SOLID principles across deliverables.
|
||||
3. Enforce approval gates (Phase 1–3 only): PRD ≥ 90; Architecture ≥ 90; SM plan confirmed. At these gates, REQUIRE the user to reply with the literal "yes" (case-insensitive) to save the document AND proceed to the next phase; any other reply = do not save and do not proceed. Phase 0 has no gate.
|
||||
4. Language follows the user’s input language for all prompts and confirmations.
|
||||
5. Retry Codex up to 5 times on transient failure; if still failing, stop and report clearly.
|
||||
6. Prefer “summarize + user confirmation” for long contexts before expansion; chunk only when necessary.
|
||||
7. Default saving is performed by the Orchestrator. In save phases Dev/QA may also write files. Only one task runs at a time (no concurrent writes).
|
||||
8. Use kebab-case `feature_name`. If no clear title, use `feat-YYYYMMDD-<short-summary>`.
|
||||
9. Store artifacts under `./.claude/specs/{feature_name}/` with canonical filenames.
|
||||
</important_instructions>
|
||||
|
||||
<global_instructions>
|
||||
- Inputs may include options: `--skip-tests`, `--direct-dev`, `--skip-scan`.
|
||||
- Derive `feature_name` from the feature title; compute `spec_dir=./.claude/specs/{feature_name}/`.
|
||||
- Artifacts:
|
||||
- `00-repo-scan.md` (unless `--skip-scan`)
|
||||
- `01-product-requirements.md` (PRD, after approval)
|
||||
- `02-system-architecture.md` (Architecture, after approval)
|
||||
- `03-sprint-plan.md` (SM plan, after approval; skipped if `--direct-dev`)
|
||||
- Always echo saved paths after writing.
|
||||
</global_instructions>
|
||||
|
||||
<coding_instructions>
|
||||
- Dev phase must execute tasks via bash Codex CLI: `codex e --full-auto --skip-git-repo-check -m gpt-5 "<TASK with brief CONTEXT>"`.
|
||||
- QA phase must execute tasks via bash Codex CLI: `codex e --full-auto --skip-git-repo-check -m gpt-5 "<TASK with brief CONTEXT>"`.
|
||||
- Treat `-m gpt-5` purely as a model parameter; avoid “agent” wording.
|
||||
- Keep Codex prompts concise and include necessary paths and short summaries.
|
||||
- Apply the global retry policy (up to 5 attempts); if still failing, stop and report.
|
||||
</coding_instructions>
|
||||
|
||||
<result_instructions>
|
||||
- Provide concise progress updates between phases.
|
||||
- Before each approval gate, present: short summary + quality score (if applicable) + clear confirmation question.
|
||||
- Gates apply to Phases 1–3 (PO/Architect/SM) only. Proceed only on explicit "yes" (case-insensitive). On "yes": save to the canonical path, echo it, and advance to the next phase.
|
||||
- Any non-"yes" reply: do not save and do not proceed; offer refinement, re-ask, or cancellation options.
|
||||
- Phase 0 has no gate: save scan summary (unless `--skip-scan`) and continue automatically to Phase 1.
|
||||
</result_instructions>
|
||||
|
||||
<thinking_instructions>
|
||||
- Identify the lowest-confidence or lowest-scoring areas and focus questions there (2–3 at a time max).
|
||||
- Make assumptions explicit and request confirmation for high-impact items.
|
||||
- Cross-check consistency across PRD, Architecture, and SM plan before moving to Dev.
|
||||
</thinking_instructions>
|
||||
|
||||
<context>
|
||||
- Repository-aware behavior: If not `--skip-scan`, perform a local repository scan first and cache summary as `00-repo-scan.md` for downstream use.
|
||||
- Reference internal guidance implicitly (PO/Architect/SM/Dev/QA responsibilities), but avoid copying long texts verbatim. Embed essential behaviors in prompts below.
|
||||
</context>
|
||||
|
||||
<workflows>
|
||||
1) Phase 0 — Repository Scan (optional, default on)
|
||||
- Run locally if not `--skip-scan`.
|
||||
- Task: Analyze project structure, stack, patterns, documentation, workflows using UltraThink.
|
||||
- Output: succinct Markdown summary.
|
||||
- Save and proceed automatically: write `spec_dir/00-repo-scan.md` and then continue to Phase 1 (no confirmation required).
|
||||
|
||||
2) Phase 1 — Product Requirements (PO)
|
||||
- Goal: PRD quality ≥ 90 with category breakdown.
|
||||
- Local prompt:
|
||||
- Role: Sarah (BMAD PO) — meticulous, analytical, user-focused.
|
||||
- Include: user request; scan summary/path if available.
|
||||
- Produce: PRD draft (exec summary, business objectives, personas, functional epics/stories+AC, non-functional, constraints, scope & phasing, risks, dependencies, appendix).
|
||||
- Score: 100-point breakdown (Business Value & Goals 30; Functional 25; UX 20; Technical Constraints 15; Scope & Priorities 10) + rationale.
|
||||
- Ask: 2–5 focused clarification questions on lowest-scoring areas.
|
||||
- No saving during drafting.
|
||||
- Loop: Ask user, refine, rescore until ≥ 90.
|
||||
- Gate: Ask confirmation (user language). Only if user replies "yes": save `01-product-requirements.md` and move to Phase 2; otherwise stay here and continue refinement.
|
||||
|
||||
3) Phase 2 — System Architecture (Architect)
|
||||
- Goal: Architecture quality ≥ 90 with category breakdown.
|
||||
- Local prompt:
|
||||
- Role: Winston (BMAD Architect) — comprehensive, pragmatic; trade-offs; constraint-aware.
|
||||
- Include: PRD content; scan summary/path.
|
||||
- Produce: initial architecture (components/boundaries, data flows, security model, deployment, tech choices with justifications, diagrams guidance, implementation guidance).
|
||||
- Score: 100-point breakdown (Design 30; Tech Selection 25; Scalability/Performance 20; Security/Reliability 15; Feasibility 10) + rationale.
|
||||
- Ask: targeted technical questions for critical decisions.
|
||||
- No saving during drafting.
|
||||
- Loop: Ask user, refine, rescore until ≥ 90.
|
||||
- Gate: Ask confirmation (user language). Only if user replies "yes": save `02-system-architecture.md` and move to Phase 3; otherwise stay here and continue refinement.
|
||||
|
||||
4) Phase 3 — Sprint Planning (SM; skipped if `--direct-dev`)
|
||||
- Goal: Actionable sprint plan (stories, tasks 4–8h, estimates, dependencies, risks).
|
||||
- Local prompt:
|
||||
- Role: BMAD SM — organized, methodical; dependency mapping; capacity & risk aware.
|
||||
- Include: scan summary/path; PRD path; Architecture path.
|
||||
- Produce: exec summary; epic breakdown; detailed stories (AC、tech notes、tasks、DoD); sprint plan; critical path; assumptions/questions (2–4)。
|
||||
- No saving during drafting.
|
||||
- Gate: Ask confirmation (user language). Only if user replies "yes": save `03-sprint-plan.md` and move to Phase 4; otherwise stay here and continue refinement.
|
||||
|
||||
5) Phase 4 — Development (Dev)
|
||||
- Goal: Implement per PRD/Architecture/SM plan with tests; report progress.
|
||||
- Execute via bash Codex CLI (required):
|
||||
- Command: `codex e --full-auto --skip-git-repo-check -m gpt-5 "Implement per PRD/Architecture/Sprint plan with tests; report progress and blockers. Context: [paths + brief summaries]."`
|
||||
- Include paths: `00-repo-scan.md` (if exists), `01-product-requirements.md`, `02-system-architecture.md`, `03-sprint-plan.md` (if exists).
|
||||
- Follow retry policy (5 attempts); if still failing, stop and report.
|
||||
- Orchestrator remains responsible for approvals and saving as needed.
|
||||
|
||||
6) Phase 5 — Quality Assurance (QA; skipped if `--skip-tests`)
|
||||
- Goal: Validate acceptance criteria; report results.
|
||||
- Execute via bash Codex CLI (required):
|
||||
- Command: `codex e --full-auto --skip-git-repo-check -m gpt-5 "Create and run tests to validate acceptance criteria; report results with failures and remediation. Context: [paths + brief summaries]."`
|
||||
- Include paths: same as Dev.
|
||||
- Follow retry policy (5 attempts); if still failing, stop and report.
|
||||
- Orchestrator collects results and summarizes quality status.
|
||||
</workflows>
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"name": "requirements-clarity",
|
||||
"source": "./",
|
||||
"description": "Transforms vague requirements into actionable PRDs through systematic clarification with 100-point scoring system",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"requirements",
|
||||
"clarification",
|
||||
"prd",
|
||||
"specifications",
|
||||
"quality-gates",
|
||||
"requirements-engineering"
|
||||
],
|
||||
"category": "essentials",
|
||||
"strict": false,
|
||||
"skills": [
|
||||
"./skills/SKILL.md"
|
||||
]
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
---
|
||||
name: Requirements Clarity
|
||||
description: Clarify ambiguous requirements through focused dialogue before implementation. Use when requirements are unclear, features are complex (>2 days), or involve cross-team coordination. Ask two core questions - Why? (YAGNI check) and Simpler? (KISS check) - to ensure clarity before coding.
|
||||
---
|
||||
|
||||
# Requirements Clarity Skill
|
||||
|
||||
## Description
|
||||
|
||||
Automatically transforms vague requirements into actionable PRDs through systematic clarification with a 100-point scoring system.
|
||||
|
||||
## Activation
|
||||
|
||||
Auto-activate when detecting vague requirements:
|
||||
|
||||
1. **Vague Feature Requests**
|
||||
- User says: "add login feature", "implement payment", "create dashboard"
|
||||
- Missing: How, with what technology, what constraints?
|
||||
|
||||
2. **Missing Technical Context**
|
||||
- No technology stack mentioned
|
||||
- No integration points identified
|
||||
- No performance/security constraints
|
||||
|
||||
3. **Incomplete Specifications**
|
||||
- No acceptance criteria
|
||||
- No success metrics
|
||||
- No edge cases considered
|
||||
- No error handling mentioned
|
||||
|
||||
4. **Ambiguous Scope**
|
||||
- Unclear boundaries ("user management" - what exactly?)
|
||||
- No distinction between MVP and future enhancements
|
||||
- Missing "what's NOT included"
|
||||
|
||||
**Do NOT activate when**:
|
||||
- Specific file paths mentioned (e.g., "auth.go:45")
|
||||
- Code snippets included
|
||||
- Existing functions/classes referenced
|
||||
- Bug fixes with clear reproduction steps
|
||||
|
||||
## Core Principles
|
||||
|
||||
1. **Systematic Questioning**
|
||||
- Ask focused, specific questions
|
||||
- One category at a time (2-3 questions per round)
|
||||
- Build on previous answers
|
||||
- Avoid overwhelming users
|
||||
|
||||
2. **Quality-Driven Iteration**
|
||||
- Continuously assess clarity score (0-100)
|
||||
- Identify gaps systematically
|
||||
- Iterate until ≥ 90 points
|
||||
- Document all clarification rounds
|
||||
|
||||
3. **Actionable Output**
|
||||
- Generate concrete specifications
|
||||
- Include measurable acceptance criteria
|
||||
- Provide executable phases
|
||||
- Enable direct implementation
|
||||
|
||||
## Clarification Process
|
||||
|
||||
### Step 1: Initial Requirement Analysis
|
||||
|
||||
**Input**: User's requirement description
|
||||
|
||||
**Tasks**:
|
||||
1. Parse and understand core requirement
|
||||
2. Generate feature name (kebab-case format)
|
||||
3. Determine document version (default `1.0` unless user specifies otherwise)
|
||||
4. Ensure `./docs/prds/` exists for PRD output
|
||||
5. Perform initial clarity assessment (0-100)
|
||||
|
||||
**Assessment Rubric**:
|
||||
```
|
||||
Functional Clarity: /30 points
|
||||
- Clear inputs/outputs: 10 pts
|
||||
- User interaction defined: 10 pts
|
||||
- Success criteria stated: 10 pts
|
||||
|
||||
Technical Specificity: /25 points
|
||||
- Technology stack mentioned: 8 pts
|
||||
- Integration points identified: 8 pts
|
||||
- Constraints specified: 9 pts
|
||||
|
||||
Implementation Completeness: /25 points
|
||||
- Edge cases considered: 8 pts
|
||||
- Error handling mentioned: 9 pts
|
||||
- Data validation specified: 8 pts
|
||||
|
||||
Business Context: /20 points
|
||||
- Problem statement clear: 7 pts
|
||||
- Target users identified: 7 pts
|
||||
- Success metrics defined: 6 pts
|
||||
```
|
||||
|
||||
**Initial Response Format**:
|
||||
```markdown
|
||||
I understand your requirement. Let me help you refine this specification.
|
||||
|
||||
**Current Clarity Score**: X/100
|
||||
|
||||
**Clear Aspects**:
|
||||
- [List what's clear]
|
||||
|
||||
**Needs Clarification**:
|
||||
- [List gaps]
|
||||
|
||||
Let me systematically clarify these points...
|
||||
```
|
||||
|
||||
### Step 2: Gap Analysis
|
||||
|
||||
Identify missing information across four dimensions:
|
||||
|
||||
**1. Functional Scope**
|
||||
- What is the core functionality?
|
||||
- What are the boundaries?
|
||||
- What is out of scope?
|
||||
- What are edge cases?
|
||||
|
||||
**2. User Interaction**
|
||||
- How do users interact?
|
||||
- What are the inputs?
|
||||
- What are the outputs?
|
||||
- What are success/failure scenarios?
|
||||
|
||||
**3. Technical Constraints**
|
||||
- Performance requirements?
|
||||
- Compatibility requirements?
|
||||
- Security considerations?
|
||||
- Scalability needs?
|
||||
|
||||
**4. Business Value**
|
||||
- What problem does this solve?
|
||||
- Who are the target users?
|
||||
- What are success metrics?
|
||||
- What is the priority?
|
||||
|
||||
### Step 3: Interactive Clarification
|
||||
|
||||
**Question Strategy**:
|
||||
1. Start with highest-impact gaps
|
||||
2. Ask 2-3 questions per round
|
||||
3. Build context progressively
|
||||
4. Use user's language
|
||||
5. Provide examples when helpful
|
||||
|
||||
**Question Format**:
|
||||
```markdown
|
||||
I need to clarify the following points to complete the requirements document:
|
||||
|
||||
1. **[Category]**: [Specific question]?
|
||||
- For example: [Example if helpful]
|
||||
|
||||
2. **[Category]**: [Specific question]?
|
||||
|
||||
3. **[Category]**: [Specific question]?
|
||||
|
||||
Please provide your answers, and I'll continue refining the PRD.
|
||||
```
|
||||
|
||||
**After Each User Response**:
|
||||
1. Update clarity score
|
||||
2. Capture new information in the working PRD outline
|
||||
3. Identify remaining gaps
|
||||
4. If score < 90: Continue with next round of questions
|
||||
5. If score ≥ 90: Proceed to PRD generation
|
||||
|
||||
**Score Update Format**:
|
||||
```markdown
|
||||
Thank you for the additional information!
|
||||
|
||||
**Clarity Score Update**: X/100 → Y/100
|
||||
|
||||
**New Clarified Content**:
|
||||
- [Summarize new information]
|
||||
|
||||
**Remaining Points to Clarify**:
|
||||
- [List remaining gaps if score < 90]
|
||||
|
||||
[If score < 90: Continue with next round of questions]
|
||||
[If score ≥ 90: "Perfect! I will now generate the complete PRD document..."]
|
||||
```
|
||||
|
||||
### Step 4: PRD Generation
|
||||
|
||||
Once clarity score ≥ 90, generate comprehensive PRD.
|
||||
|
||||
**Output File**:
|
||||
|
||||
1. **Final PRD**: `./docs/prds/{feature_name}-v{version}-prd.md`
|
||||
|
||||
Use the `Write` tool to create or update this file. Derive `{version}` from the document version recorded in the PRD (default `1.0`).
|
||||
|
||||
## PRD Document Structure
|
||||
|
||||
```markdown
|
||||
# {Feature Name} - Product Requirements Document (PRD)
|
||||
|
||||
## Requirements Description
|
||||
|
||||
### Background
|
||||
- **Business Problem**: [Describe the business problem to solve]
|
||||
- **Target Users**: [Target user groups]
|
||||
- **Value Proposition**: [Value this feature brings]
|
||||
|
||||
### Feature Overview
|
||||
- **Core Features**: [List of main features]
|
||||
- **Feature Boundaries**: [What is and isn't included]
|
||||
- **User Scenarios**: [Typical usage scenarios]
|
||||
|
||||
### Detailed Requirements
|
||||
- **Input/Output**: [Specific input/output specifications]
|
||||
- **User Interaction**: [User operation flow]
|
||||
- **Data Requirements**: [Data structures and validation rules]
|
||||
- **Edge Cases**: [Edge case handling]
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### Technical Approach
|
||||
- **Architecture Choice**: [Technical architecture decisions and rationale]
|
||||
- **Key Components**: [List of main technical components]
|
||||
- **Data Storage**: [Data models and storage solutions]
|
||||
- **Interface Design**: [API/interface specifications]
|
||||
|
||||
### Constraints
|
||||
- **Performance Requirements**: [Response time, throughput, etc.]
|
||||
- **Compatibility**: [System compatibility requirements]
|
||||
- **Security**: [Security considerations]
|
||||
- **Scalability**: [Future expansion considerations]
|
||||
|
||||
### Risk Assessment
|
||||
- **Technical Risks**: [Potential technical risks and mitigation plans]
|
||||
- **Dependency Risks**: [External dependencies and alternatives]
|
||||
- **Schedule Risks**: [Timeline risks and response strategies]
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Acceptance
|
||||
- [ ] Feature 1: [Specific acceptance conditions]
|
||||
- [ ] Feature 2: [Specific acceptance conditions]
|
||||
- [ ] Feature 3: [Specific acceptance conditions]
|
||||
|
||||
### Quality Standards
|
||||
- [ ] Code Quality: [Code standards and review requirements]
|
||||
- [ ] Test Coverage: [Testing requirements and coverage]
|
||||
- [ ] Performance Metrics: [Performance test pass criteria]
|
||||
- [ ] Security Review: [Security review requirements]
|
||||
|
||||
### User Acceptance
|
||||
- [ ] User Experience: [UX acceptance criteria]
|
||||
- [ ] Documentation: [Documentation delivery requirements]
|
||||
- [ ] Training Materials: [If needed, training material requirements]
|
||||
|
||||
## Execution Phases
|
||||
|
||||
### Phase 1: Preparation
|
||||
**Goal**: Environment preparation and technical validation
|
||||
- [ ] Task 1: [Specific task description]
|
||||
- [ ] Task 2: [Specific task description]
|
||||
- **Deliverables**: [Phase deliverables]
|
||||
- **Time**: [Estimated time]
|
||||
|
||||
### Phase 2: Core Development
|
||||
**Goal**: Implement core functionality
|
||||
- [ ] Task 1: [Specific task description]
|
||||
- [ ] Task 2: [Specific task description]
|
||||
- **Deliverables**: [Phase deliverables]
|
||||
- **Time**: [Estimated time]
|
||||
|
||||
### Phase 3: Integration & Testing
|
||||
**Goal**: Integration and quality assurance
|
||||
- [ ] Task 1: [Specific task description]
|
||||
- [ ] Task 2: [Specific task description]
|
||||
- **Deliverables**: [Phase deliverables]
|
||||
- **Time**: [Estimated time]
|
||||
|
||||
### Phase 4: Deployment
|
||||
**Goal**: Release and monitoring
|
||||
- [ ] Task 1: [Specific task description]
|
||||
- [ ] Task 2: [Specific task description]
|
||||
- **Deliverables**: [Phase deliverables]
|
||||
- **Time**: [Estimated time]
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Created**: {timestamp}
|
||||
**Clarification Rounds**: {clarification_rounds}
|
||||
**Quality Score**: {quality_score}/100
|
||||
```
|
||||
|
||||
## Behavioral Guidelines
|
||||
|
||||
### DO
|
||||
- Ask specific, targeted questions
|
||||
- Build on previous answers
|
||||
- Provide examples to guide users
|
||||
- Maintain conversational tone
|
||||
- Summarize clarification rounds within the PRD
|
||||
- Use clear, professional English
|
||||
- Generate concrete specifications
|
||||
- Stay in clarification mode until score ≥ 90
|
||||
|
||||
### DON'T
|
||||
- Ask all questions at once
|
||||
- Make assumptions without confirmation
|
||||
- Generate PRD before 90+ score
|
||||
- Skip any required sections
|
||||
- Use vague or abstract language
|
||||
- Proceed without user responses
|
||||
- Exit skill mode prematurely
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- Clarity score ≥ 90/100
|
||||
- All PRD sections complete with substance
|
||||
- Acceptance criteria checklistable (using `- [ ]` format)
|
||||
- Execution phases actionable with concrete tasks
|
||||
- User approves final PRD
|
||||
- Ready for development handoff
|
||||
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"name": "requirements-driven-development",
|
||||
"source": "./",
|
||||
"description": "Streamlined requirements-driven development workflow with 90% quality gates for practical feature implementation",
|
||||
"version": "1.0.0",
|
||||
"author": {
|
||||
"name": "Claude Code Dev Workflows",
|
||||
"url": "https://github.com/cexll/myclaude"
|
||||
},
|
||||
"homepage": "https://github.com/cexll/myclaude",
|
||||
"repository": "https://github.com/cexll/myclaude",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"requirements",
|
||||
"workflow",
|
||||
"automation",
|
||||
"quality-gates",
|
||||
"feature-development",
|
||||
"agile",
|
||||
"specifications"
|
||||
],
|
||||
"category": "workflows",
|
||||
"strict": false,
|
||||
"commands": [
|
||||
"./commands/requirements-pilot.md"
|
||||
],
|
||||
"agents": [
|
||||
"./agents/requirements-generate.md",
|
||||
"./agents/requirements-code.md",
|
||||
"./agents/requirements-testing.md",
|
||||
"./agents/requirements-review.md"
|
||||
]
|
||||
}
|
||||
9
requirements-driven-workflow/.claude-plugin/plugin.json
Normal file
9
requirements-driven-workflow/.claude-plugin/plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "requirements",
|
||||
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
|
||||
"version": "5.6.1",
|
||||
"author": {
|
||||
"name": "cexll",
|
||||
"email": "cexll@cexll.com"
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,10 @@ You adhere to core software engineering principles like KISS (Keep It Simple, St
|
||||
|
||||
## Implementation Constraints
|
||||
|
||||
### Language Rules
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, SQL, CRUD, etc.) in English; translate explanatory text only.
|
||||
|
||||
### MUST Requirements
|
||||
- **Working Solution**: Code must fully implement the specified functionality
|
||||
- **Integration Compatibility**: Must work seamlessly with existing codebase
|
||||
|
||||
@@ -88,6 +88,10 @@ Each phase should be independently deployable and testable.
|
||||
|
||||
## Key Constraints
|
||||
|
||||
### Language Rules
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, SQL, CRUD, etc.) in English; translate explanatory text only.
|
||||
|
||||
### MUST Requirements
|
||||
- **Direct Implementability**: Every item must be directly translatable to code
|
||||
- **Specific Technical Details**: Include exact file paths, function names, table schemas
|
||||
|
||||
@@ -176,6 +176,10 @@ You adhere to core software engineering principles like KISS (Keep It Simple, St
|
||||
|
||||
## Key Constraints
|
||||
|
||||
### Language Rules
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, etc.) in English; translate explanatory text only.
|
||||
|
||||
### MUST Requirements
|
||||
- **Functional Verification**: Verify all specified functionality works
|
||||
- **Integration Testing**: Ensure seamless integration with existing code
|
||||
|
||||
@@ -199,6 +199,10 @@ func TestAPIEndpoint(t *testing.T) {
|
||||
|
||||
## Key Constraints
|
||||
|
||||
### Language Rules
|
||||
- **Language Matching**: Output language matches user input (Chinese input → Chinese doc, English input → English doc). When language is ambiguous, default to Chinese.
|
||||
- **Technical Terms**: Keep technical terms (API, E2E, CI/CD, Mock, etc.) in English; translate explanatory text only.
|
||||
|
||||
### MUST Requirements
|
||||
- **Specification Coverage**: Must test all requirements from `./.claude/specs/{feature_name}/requirements-spec.md`
|
||||
- **Critical Path Testing**: Must test all critical business functionality
|
||||
|
||||
73
skills/browser/SKILL.md
Normal file
73
skills/browser/SKILL.md
Normal file
@@ -0,0 +1,73 @@
|
||||
---
|
||||
name: browser
|
||||
description: This skill should be used for browser automation tasks using Chrome DevTools Protocol (CDP). Triggers when users need to launch Chrome with remote debugging, navigate pages, execute JavaScript in browser context, capture screenshots, or interactively select DOM elements. No MCP server required.
|
||||
---
|
||||
|
||||
# Browser Automation
|
||||
|
||||
Minimal Chrome DevTools Protocol (CDP) helpers for browser automation without MCP server setup.
|
||||
|
||||
## Setup
|
||||
|
||||
Install dependencies before first use:
|
||||
|
||||
```bash
|
||||
npm install --prefix ~/.claude/skills/browser/browser ws
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
All scripts connect to Chrome on `localhost:9222`.
|
||||
|
||||
### start.js - Launch Chrome
|
||||
|
||||
```bash
|
||||
scripts/start.js # Fresh profile
|
||||
scripts/start.js --profile # Use persistent profile (keeps cookies/auth)
|
||||
```
|
||||
|
||||
### nav.js - Navigate
|
||||
|
||||
```bash
|
||||
scripts/nav.js https://example.com # Navigate current tab
|
||||
scripts/nav.js https://example.com --new # Open in new tab
|
||||
```
|
||||
|
||||
### eval.js - Execute JavaScript
|
||||
|
||||
```bash
|
||||
scripts/eval.js 'document.title'
|
||||
scripts/eval.js '(() => { const x = 1; return x + 1; })()'
|
||||
```
|
||||
|
||||
Use single expressions or IIFE for multiple statements.
|
||||
|
||||
### screenshot.js - Capture Screenshot
|
||||
|
||||
```bash
|
||||
scripts/screenshot.js
|
||||
```
|
||||
|
||||
Returns `{ path, filename }` of saved PNG in temp directory.
|
||||
|
||||
### pick.js - Visual Element Picker
|
||||
|
||||
```bash
|
||||
scripts/pick.js "Click the submit button"
|
||||
```
|
||||
|
||||
Returns element metadata: tag, id, classes, text, href, selector, rect.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Launch Chrome: `scripts/start.js --profile` for authenticated sessions
|
||||
2. Navigate: `scripts/nav.js <url>`
|
||||
3. Inspect: `scripts/eval.js 'document.querySelector(...)'`
|
||||
4. Capture: `scripts/screenshot.js` or `scripts/pick.js`
|
||||
5. Return gathered data
|
||||
|
||||
## Key Points
|
||||
|
||||
- All operations run locally - credentials never leave the machine
|
||||
- Use `--profile` flag to preserve cookies and auth tokens
|
||||
- Scripts return structured JSON for agent consumption
|
||||
BIN
skills/browser/browser.zip
Normal file
BIN
skills/browser/browser.zip
Normal file
Binary file not shown.
33
skills/browser/package-lock.json
generated
Normal file
33
skills/browser/package-lock.json
generated
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "browser",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
skills/browser/package.json
Normal file
5
skills/browser/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"ws": "^8.18.3"
|
||||
}
|
||||
}
|
||||
62
skills/browser/scripts/eval.cjs
Executable file
62
skills/browser/scripts/eval.cjs
Executable file
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env node
|
||||
// Execute JavaScript in the active browser tab
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const code = process.argv[2];
|
||||
if (!code) {
|
||||
console.error('Usage: eval.js <javascript-expression>');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function getTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('http://localhost:9222/json', res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const targets = await getTargets();
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (!page) throw new Error('No active page found');
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({
|
||||
id: 1,
|
||||
method: 'Runtime.evaluate',
|
||||
params: {
|
||||
expression: code,
|
||||
returnByValue: true,
|
||||
awaitPromise: true
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', data => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id === 1) {
|
||||
ws.close();
|
||||
if (msg.result.exceptionDetails) {
|
||||
console.error('Error:', msg.result.exceptionDetails.text);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify(msg.result.result.value ?? msg.result.result));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', e => {
|
||||
console.error('WebSocket error:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
70
skills/browser/scripts/nav.cjs
Executable file
70
skills/browser/scripts/nav.cjs
Executable file
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env node
|
||||
// Navigate to URL in current or new tab
|
||||
const http = require('http');
|
||||
|
||||
const url = process.argv[2];
|
||||
const newTab = process.argv.includes('--new');
|
||||
|
||||
if (!url) {
|
||||
console.error('Usage: nav.js <url> [--new]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function getTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('http://localhost:9222/json', res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function createTab(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(`http://localhost:9222/json/new?${encodeURIComponent(url)}`, res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function navigate(targetId, url) {
|
||||
const WebSocket = require('ws');
|
||||
const targets = await getTargets();
|
||||
const target = targets.find(t => t.id === targetId);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({ id: 1, method: 'Page.navigate', params: { url } }));
|
||||
});
|
||||
ws.on('message', data => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id === 1) {
|
||||
ws.close();
|
||||
resolve(msg.result);
|
||||
}
|
||||
});
|
||||
ws.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (newTab) {
|
||||
const tab = await createTab(url);
|
||||
console.log(JSON.stringify({ action: 'created', tabId: tab.id, url }));
|
||||
} else {
|
||||
const targets = await getTargets();
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (!page) throw new Error('No active page found');
|
||||
await navigate(page.id, url);
|
||||
console.log(JSON.stringify({ action: 'navigated', tabId: page.id, url }));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
87
skills/browser/scripts/pick.cjs
Executable file
87
skills/browser/scripts/pick.cjs
Executable file
@@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
// Visual element picker - click to select DOM nodes
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const hint = process.argv[2] || 'Click an element to select it';
|
||||
|
||||
async function getTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('http://localhost:9222/json', res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const pickerScript = `
|
||||
(function(hint) {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;z-index:999999;cursor:crosshair;';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.textContent = hint;
|
||||
label.style.cssText = 'position:fixed;top:10px;left:50%;transform:translateX(-50%);background:#333;color:#fff;padding:8px 16px;border-radius:4px;z-index:1000000;font:14px sans-serif;';
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
document.body.appendChild(label);
|
||||
|
||||
overlay.onclick = e => {
|
||||
overlay.remove();
|
||||
label.remove();
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!el) return resolve(null);
|
||||
|
||||
const rect = el.getBoundingClientRect();
|
||||
resolve({
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
classes: [...el.classList],
|
||||
text: el.textContent?.slice(0, 100)?.trim() || null,
|
||||
href: el.href || null,
|
||||
selector: el.id ? '#' + el.id : el.className ? el.tagName.toLowerCase() + '.' + [...el.classList].join('.') : el.tagName.toLowerCase(),
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
});
|
||||
};
|
||||
});
|
||||
})`;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const targets = await getTargets();
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (!page) throw new Error('No active page found');
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({
|
||||
id: 1,
|
||||
method: 'Runtime.evaluate',
|
||||
params: {
|
||||
expression: `${pickerScript}(${JSON.stringify(hint)})`,
|
||||
returnByValue: true,
|
||||
awaitPromise: true
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', data => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id === 1) {
|
||||
ws.close();
|
||||
console.log(JSON.stringify(msg.result.result.value, null, 2));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', e => {
|
||||
console.error('WebSocket error:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
54
skills/browser/scripts/screenshot.cjs
Executable file
54
skills/browser/scripts/screenshot.cjs
Executable file
@@ -0,0 +1,54 @@
|
||||
#!/usr/bin/env node
|
||||
// Capture screenshot of the active browser tab
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
async function getTargets() {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get('http://localhost:9222/json', res => {
|
||||
let data = '';
|
||||
res.on('data', chunk => data += chunk);
|
||||
res.on('end', () => resolve(JSON.parse(data)));
|
||||
}).on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const targets = await getTargets();
|
||||
const page = targets.find(t => t.type === 'page');
|
||||
if (!page) throw new Error('No active page found');
|
||||
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
|
||||
ws.on('open', () => {
|
||||
ws.send(JSON.stringify({
|
||||
id: 1,
|
||||
method: 'Page.captureScreenshot',
|
||||
params: { format: 'png' }
|
||||
}));
|
||||
});
|
||||
|
||||
ws.on('message', data => {
|
||||
const msg = JSON.parse(data);
|
||||
if (msg.id === 1) {
|
||||
ws.close();
|
||||
const filename = `screenshot-${Date.now()}.png`;
|
||||
const filepath = path.join(os.tmpdir(), filename);
|
||||
fs.writeFileSync(filepath, Buffer.from(msg.result.data, 'base64'));
|
||||
console.log(JSON.stringify({ path: filepath, filename }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', e => {
|
||||
console.error('WebSocket error:', e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user