mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-09 03:09:30 +08:00
Compare commits
216 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fa6843d9 | ||
|
|
74e4d181c2 | ||
|
|
04fa1626ae | ||
|
|
c0f61d5cc2 | ||
|
|
716d1eb173 | ||
|
|
4bc9ffa907 | ||
|
|
c6c2f93e02 | ||
|
|
cd3115446d | ||
|
|
2b8bfd714c | ||
|
|
71485558df | ||
|
|
b711b44c0e | ||
|
|
eda2475543 | ||
|
|
2c0553794a | ||
|
|
c96193fca6 | ||
|
|
e2cd5be812 | ||
|
|
3dfa447f10 | ||
|
|
e9a8013c6f | ||
|
|
3d76d46336 | ||
|
|
5a50131a13 | ||
|
|
fca5c13c8d | ||
|
|
c1d3a0a07a | ||
|
|
2856055e2e | ||
|
|
a9c1e8178f | ||
|
|
1afeca88ae | ||
|
|
326ad85c74 | ||
|
|
e66bec0083 | ||
|
|
eb066395c2 | ||
|
|
b49dad842a | ||
|
|
d98086c661 | ||
|
|
0420646258 | ||
|
|
19a8d8e922 | ||
|
|
669b1d82ce | ||
|
|
a21c31fd89 | ||
|
|
773f133111 | ||
|
|
4f5d24531c | ||
|
|
cc24d43c8b | ||
|
|
27d4ac8afd | ||
|
|
2e5d12570d | ||
|
|
7c89c40e8f | ||
|
|
fa617d1599 | ||
|
|
90c630e30e | ||
|
|
25bbbc32a7 | ||
|
|
d8304bf2b9 | ||
|
|
7240e08900 | ||
|
|
e122d8ff25 | ||
|
|
6985a30a6a | ||
|
|
dd4c12b8e2 | ||
|
|
a88315d92d | ||
|
|
d1f13b3379 | ||
|
|
5d362852ab | ||
|
|
238c7b9a13 | ||
|
|
0986fa82ee | ||
|
|
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 | ||
|
|
b172343235 |
@@ -1,261 +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": {
|
"owner": {
|
||||||
"name": "Claude Code Dev Workflows",
|
"name": "cexll",
|
||||||
"email": "contact@example.com",
|
"email": "evanxian9@gmail.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"
|
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
"name": "requirements-driven-development",
|
"name": "omo",
|
||||||
"source": "./requirements-driven-workflow/",
|
"description": "Multi-agent orchestration for code analysis, bug investigation, fix planning, and implementation with intelligent routing to specialized agents",
|
||||||
"description": "Streamlined requirements-driven development workflow with 90% quality gates for practical feature implementation",
|
"version": "5.6.1",
|
||||||
"version": "1.0.0",
|
"source": "./skills/omo",
|
||||||
"author": {
|
"category": "development"
|
||||||
"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": "bmad-agile-workflow",
|
"name": "requirements",
|
||||||
"source": "./bmad-agile-workflow/",
|
"description": "Requirements-driven development workflow with quality gates for practical feature implementation",
|
||||||
|
"version": "5.6.1",
|
||||||
|
"source": "./agents/requirements",
|
||||||
|
"category": "development"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "bmad",
|
||||||
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
"description": "Full BMAD agile workflow with role-based agents (PO, Architect, SM, Dev, QA) and interactive approval gates",
|
||||||
"version": "1.0.0",
|
"version": "5.6.1",
|
||||||
"author": {
|
"source": "./agents/bmad",
|
||||||
"name": "Claude Code Dev Workflows",
|
"category": "development"
|
||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "development-essentials",
|
"name": "dev-kit",
|
||||||
"source": "./development-essentials/",
|
|
||||||
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
"description": "Essential development commands for coding, debugging, testing, optimization, and documentation",
|
||||||
"version": "1.0.0",
|
"version": "5.6.1",
|
||||||
"author": {
|
"source": "./agents/development-essentials",
|
||||||
"name": "Claude Code Dev Workflows",
|
"category": "productivity"
|
||||||
"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",
|
"name": "sparv",
|
||||||
"source": "./advanced-ai-agents/",
|
"description": "Minimal SPARV workflow (Specify→Plan→Act→Review→Vault) with 10-point spec gate, unified journal, 2-action saves, 3-failure protocol, and EHRB risk detection",
|
||||||
"description": "Advanced AI agent for complex problem solving and deep analysis with GPT-5 integration",
|
"version": "1.1.0",
|
||||||
"version": "1.0.0",
|
"source": "./skills/sparv",
|
||||||
"author": {
|
"category": "development"
|
||||||
"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"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "dev-workflow",
|
|
||||||
"source": "./dev-workflow/",
|
|
||||||
"description": "Minimal lightweight development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage",
|
|
||||||
"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": [
|
|
||||||
"dev",
|
|
||||||
"workflow",
|
|
||||||
"codex",
|
|
||||||
"testing",
|
|
||||||
"coverage",
|
|
||||||
"concurrent",
|
|
||||||
"lightweight"
|
|
||||||
],
|
|
||||||
"category": "workflows",
|
|
||||||
"strict": false,
|
|
||||||
"commands": [
|
|
||||||
"./commands/dev.md"
|
|
||||||
],
|
|
||||||
"agents": [
|
|
||||||
"./agents/dev-plan-generator.md"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
||||||
39
.github/workflows/ci.yml
vendored
Normal file
39
.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master, rc/*]
|
||||||
|
pull_request:
|
||||||
|
branches: [master, rc/*]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
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 ./...
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Check coverage
|
||||||
|
run: |
|
||||||
|
cd codeagent-wrapper
|
||||||
|
go tool cover -func=coverage.out | grep total | awk '{print $3}'
|
||||||
|
shell: bash
|
||||||
|
|
||||||
|
- name: Upload coverage
|
||||||
|
uses: codecov/codecov-action@v4
|
||||||
|
with:
|
||||||
|
file: codeagent-wrapper/coverage.out
|
||||||
|
continue-on-error: true
|
||||||
58
.github/workflows/release.yml
vendored
58
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Release codex-wrapper
|
name: Release codeagent-wrapper
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
go-version: '1.21'
|
go-version: '1.21'
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
working-directory: codex-wrapper
|
working-directory: codeagent-wrapper
|
||||||
run: go test -v -coverprofile=cover.out ./...
|
run: go test -v -coverprofile=cover.out ./...
|
||||||
|
|
||||||
- name: Check coverage
|
- name: Check coverage
|
||||||
working-directory: codex-wrapper
|
working-directory: codeagent-wrapper
|
||||||
run: |
|
run: |
|
||||||
go tool cover -func=cover.out | grep total
|
go tool cover -func=cover.out | grep total
|
||||||
COVERAGE=$(go tool cover -func=cover.out | grep total | awk '{print $3}' | sed 's/%//')
|
COVERAGE=$(go tool cover -func=cover.out | grep total | awk '{print $3}' | sed 's/%//')
|
||||||
@@ -47,6 +47,10 @@ jobs:
|
|||||||
goarch: amd64
|
goarch: amd64
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
goarch: arm64
|
goarch: arm64
|
||||||
|
- goos: windows
|
||||||
|
goarch: amd64
|
||||||
|
- goos: windows
|
||||||
|
goarch: arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
@@ -58,22 +62,27 @@ jobs:
|
|||||||
go-version: '1.21'
|
go-version: '1.21'
|
||||||
|
|
||||||
- name: Build binary
|
- name: Build binary
|
||||||
working-directory: codex-wrapper
|
id: build
|
||||||
|
working-directory: codeagent-wrapper
|
||||||
env:
|
env:
|
||||||
GOOS: ${{ matrix.goos }}
|
GOOS: ${{ matrix.goos }}
|
||||||
GOARCH: ${{ matrix.goarch }}
|
GOARCH: ${{ matrix.goarch }}
|
||||||
CGO_ENABLED: 0
|
CGO_ENABLED: 0
|
||||||
run: |
|
run: |
|
||||||
VERSION=${GITHUB_REF#refs/tags/}
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
OUTPUT_NAME=codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
OUTPUT_NAME=codeagent-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} .
|
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||||
|
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||||
|
fi
|
||||||
|
go build -ldflags="-s -w -X codeagent-wrapper/internal/app.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent-wrapper
|
||||||
chmod +x ${OUTPUT_NAME}
|
chmod +x ${OUTPUT_NAME}
|
||||||
|
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
name: codeagent-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
||||||
path: codex-wrapper/codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
|
path: ${{ steps.build.outputs.artifact_path }}
|
||||||
|
|
||||||
release:
|
release:
|
||||||
name: Create Release
|
name: Create Release
|
||||||
@@ -82,6 +91,33 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Generate Release Notes
|
||||||
|
id: release_notes
|
||||||
|
run: |
|
||||||
|
# Get previous tag
|
||||||
|
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^${{ github.ref_name }}$" | head -n 1)
|
||||||
|
|
||||||
|
if [ -z "$PREVIOUS_TAG" ]; then
|
||||||
|
echo "No previous tag found, using all commits"
|
||||||
|
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||||
|
else
|
||||||
|
echo "Generating notes from $PREVIOUS_TAG to ${{ github.ref_name }}"
|
||||||
|
COMMITS=$(git log ${PREVIOUS_TAG}..${{ github.ref_name }} --pretty=format:"- %s (%h)" --no-merges)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create release notes
|
||||||
|
cat > release_notes.md <<EOF
|
||||||
|
## What's Changed
|
||||||
|
|
||||||
|
${COMMITS}
|
||||||
|
|
||||||
|
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${{ github.ref_name }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat release_notes.md
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
@@ -91,14 +127,14 @@ jobs:
|
|||||||
- name: Prepare release files
|
- name: Prepare release files
|
||||||
run: |
|
run: |
|
||||||
mkdir -p release
|
mkdir -p release
|
||||||
find artifacts -type f -name "codex-wrapper-*" -exec mv {} release/ \;
|
find artifacts -type f -name "codeagent-wrapper-*" -exec mv {} release/ \;
|
||||||
cp install.sh release/
|
cp install.sh install.bat release/
|
||||||
ls -la release/
|
ls -la release/
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: release/*
|
files: release/*
|
||||||
generate_release_notes: true
|
body_path: release_notes.md
|
||||||
draft: false
|
draft: false
|
||||||
prerelease: false
|
prerelease: false
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -1,3 +1,12 @@
|
|||||||
CLAUDE.md
|
|
||||||
.claude/
|
.claude/
|
||||||
.claude-trace
|
.claude-trace
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
.venv
|
||||||
|
.pytest_cache
|
||||||
|
__pycache__
|
||||||
|
.coverage
|
||||||
|
coverage.out
|
||||||
|
references
|
||||||
|
output/
|
||||||
|
.worktrees/
|
||||||
|
|||||||
1157
CHANGELOG.md
Normal file
1157
CHANGELOG.md
Normal file
File diff suppressed because it is too large
Load Diff
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/>.
|
||||||
30
Makefile
30
Makefile
@@ -1,16 +1,18 @@
|
|||||||
# Claude Code Multi-Agent Workflow System Makefile
|
# Claude Code Multi-Agent Workflow System Makefile
|
||||||
# Quick deployment for BMAD and Requirements workflows
|
# 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
|
# Default target
|
||||||
help:
|
help:
|
||||||
@echo "Claude Code Multi-Agent Workflow - Quick Deployment"
|
@echo "Claude Code Multi-Agent Workflow - Quick Deployment"
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "Recommended installation: npx github:cexll/myclaude"
|
||||||
|
@echo ""
|
||||||
@echo "Usage: make [target]"
|
@echo "Usage: make [target]"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
@echo " install - Install all configurations to Claude Code"
|
@echo " install - LEGACY: install all configurations (prefer npx github:cexll/myclaude)"
|
||||||
@echo " deploy-bmad - Deploy BMAD workflow (bmad-pilot)"
|
@echo " deploy-bmad - Deploy BMAD workflow (bmad-pilot)"
|
||||||
@echo " deploy-requirements - Deploy Requirements workflow (requirements-pilot)"
|
@echo " deploy-requirements - Deploy Requirements workflow (requirements-pilot)"
|
||||||
@echo " deploy-essentials - Deploy Development Essentials workflow"
|
@echo " deploy-essentials - Deploy Development Essentials workflow"
|
||||||
@@ -20,6 +22,7 @@ help:
|
|||||||
@echo " deploy-all - Deploy everything (commands + agents)"
|
@echo " deploy-all - Deploy everything (commands + agents)"
|
||||||
@echo " test-bmad - Test BMAD workflow with sample"
|
@echo " test-bmad - Test BMAD workflow with sample"
|
||||||
@echo " test-requirements - Test Requirements 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 " clean - Clean generated artifacts"
|
||||||
@echo " help - Show this help message"
|
@echo " help - Show this help message"
|
||||||
|
|
||||||
@@ -28,14 +31,16 @@ CLAUDE_CONFIG_DIR = ~/.claude
|
|||||||
SPECS_DIR = .claude/specs
|
SPECS_DIR = .claude/specs
|
||||||
|
|
||||||
# Workflow directories
|
# Workflow directories
|
||||||
BMAD_DIR = bmad-agile-workflow
|
BMAD_DIR = agents/bmad
|
||||||
REQUIREMENTS_DIR = requirements-driven-workflow
|
REQUIREMENTS_DIR = agents/requirements
|
||||||
ESSENTIALS_DIR = development-essentials
|
ESSENTIALS_DIR = agents/development-essentials
|
||||||
ADVANCED_DIR = advanced-ai-agents
|
ADVANCED_DIR = advanced-ai-agents
|
||||||
OUTPUT_STYLES_DIR = output-styles
|
OUTPUT_STYLES_DIR = output-styles
|
||||||
|
|
||||||
# Install all configurations
|
# Install all configurations
|
||||||
install: deploy-all
|
install: deploy-all
|
||||||
|
@echo "⚠️ LEGACY PATH: make install will be removed in future versions."
|
||||||
|
@echo " Prefer: npx github:cexll/myclaude"
|
||||||
@echo "✅ Installation complete!"
|
@echo "✅ Installation complete!"
|
||||||
|
|
||||||
# Deploy BMAD workflow
|
# Deploy BMAD workflow
|
||||||
@@ -140,4 +145,17 @@ all: deploy-all
|
|||||||
# Version info
|
# Version info
|
||||||
version:
|
version:
|
||||||
@echo "Claude Code Multi-Agent Workflow System v3.1"
|
@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"
|
||||||
|
|||||||
18
PLUGIN_README.md
Normal file
18
PLUGIN_README.md
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Plugin System
|
||||||
|
|
||||||
|
Claude Code plugins for this repo are defined in `.claude-plugin/marketplace.json`.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/plugin marketplace add cexll/myclaude
|
||||||
|
/plugin list
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Plugins
|
||||||
|
|
||||||
|
- `bmad` - BMAD workflow (`./agents/bmad`)
|
||||||
|
- `requirements` - requirements-driven workflow (`./agents/requirements`)
|
||||||
|
- `dev-kit` - development essentials (`./agents/development-essentials`)
|
||||||
|
- `omo` - orchestration skill (`./skills/omo`)
|
||||||
|
- `sparv` - SPARV workflow (`./skills/sparv`)
|
||||||
223
README.md
223
README.md
@@ -1,128 +1,151 @@
|
|||||||
|
[中文](README_CN.md) [English](README.md)
|
||||||
|
|
||||||
# Claude Code Multi-Agent Workflow System
|
# 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://claude.ai/code)
|
||||||
[](https://github.com/cexll/myclaude)
|
[](https://github.com/cexll/myclaude)
|
||||||
[](https://docs.claude.com/en/docs/claude-code/plugins)
|
|
||||||
|
|
||||||
> Enterprise-grade agile development automation with AI-powered multi-agent orchestration
|
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini/OpenCode)
|
||||||
|
|
||||||
[中文文档](README_CN.md) | [Documentation](docs/)
|
## Quick Start
|
||||||
|
|
||||||
## 🚀 Quick Start
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
**Plugin System (Recommended)**
|
|
||||||
```bash
|
|
||||||
/plugin marketplace add cexll/myclaude
|
|
||||||
```
|
|
||||||
|
|
||||||
**Traditional Installation**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/cexll/myclaude.git
|
|
||||||
cd myclaude
|
|
||||||
make install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Basic Usage
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Full agile workflow
|
npx github:cexll/myclaude
|
||||||
/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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 Plugin Modules
|
## Modules Overview
|
||||||
|
|
||||||
| Plugin | Description | Key Commands |
|
| Module | Description | Documentation |
|
||||||
|--------|-------------|--------------|
|
|--------|-------------|---------------|
|
||||||
| **[bmad-agile-workflow](docs/BMAD-WORKFLOW.md)** | Complete BMAD methodology with 6 specialized agents | `/bmad-pilot` |
|
| [do](skills/do/README.md) | **Recommended** - 7-phase feature development with codeagent orchestration | `/do` command |
|
||||||
| **[requirements-driven-workflow](docs/REQUIREMENTS-WORKFLOW.md)** | Streamlined requirements-to-code workflow | `/requirements-pilot` |
|
| [omo](skills/omo/README.md) | Multi-agent orchestration with intelligent routing | `/omo` command |
|
||||||
| **[dev-workflow](dev-workflow/README.md)** | Extreme lightweight end-to-end development workflow | `/dev` |
|
| [bmad](agents/bmad/README.md) | BMAD agile workflow with 6 specialized agents | `/bmad-pilot` command |
|
||||||
| **[codex-wrapper](codex-wrapper/)** | Go binary wrapper for Codex CLI integration | `codex-wrapper` |
|
| [requirements](agents/requirements/README.md) | Lightweight requirements-to-code pipeline | `/requirements-pilot` command |
|
||||||
| **[development-essentials](docs/DEVELOPMENT-COMMANDS.md)** | Core development slash commands | `/code` `/debug` `/test` `/optimize` |
|
| [essentials](agents/development-essentials/README.md) | Core development commands and utilities | `/code`, `/debug`, etc. |
|
||||||
| **[advanced-ai-agents](docs/ADVANCED-AGENTS.md)** | GPT-5 deep reasoning integration | Agent: `gpt5` |
|
| [sparv](skills/sparv/README.md) | SPARV workflow (Specify→Plan→Act→Review→Vault) | `/sparv` command |
|
||||||
| **[requirements-clarity](docs/REQUIREMENTS-CLARITY.md)** | Automated requirements clarification with 100-point scoring | Auto-activated skill |
|
| course | Course development (combines dev + product-requirements + test-cases) | Composite module |
|
||||||
|
|
||||||
## 💡 Use Cases
|
## Installation
|
||||||
|
|
||||||
**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
|
|
||||||
|
|
||||||
**Codex Wrapper** (Go binary for Codex CLI)
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/cexll/myclaude/refs/heads/master/install.sh | bash
|
# Interactive installer (recommended)
|
||||||
|
npx github:cexll/myclaude
|
||||||
|
|
||||||
|
# List installable items (modules / skills / wrapper)
|
||||||
|
npx github:cexll/myclaude --list
|
||||||
|
|
||||||
|
# Detect installed modules and update from GitHub
|
||||||
|
npx github:cexll/myclaude --update
|
||||||
|
|
||||||
|
# Custom install directory / overwrite
|
||||||
|
npx github:cexll/myclaude --install-dir ~/.claude --force
|
||||||
```
|
```
|
||||||
|
|
||||||
**Method 1: Plugin Install** (One command)
|
`--update` detects already installed modules in the target install dir (defaults to `~/.claude`, via `installed_modules.json` when present) and updates them from GitHub (latest release) by overwriting the module files.
|
||||||
|
|
||||||
|
### Module Configuration
|
||||||
|
|
||||||
|
Edit `config.json` to enable/disable modules:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modules": {
|
||||||
|
"bmad": { "enabled": false },
|
||||||
|
"requirements": { "enabled": false },
|
||||||
|
"essentials": { "enabled": false },
|
||||||
|
"omo": { "enabled": false },
|
||||||
|
"sparv": { "enabled": false },
|
||||||
|
"do": { "enabled": true },
|
||||||
|
"course": { "enabled": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Workflow Selection Guide
|
||||||
|
|
||||||
|
| Scenario | Recommended |
|
||||||
|
|----------|-------------|
|
||||||
|
| Feature development (default) | `/do` |
|
||||||
|
| Bug investigation + fix | `/omo` |
|
||||||
|
| Large enterprise project | `/bmad-pilot` |
|
||||||
|
| Quick prototype | `/requirements-pilot` |
|
||||||
|
| Simple task | `/code`, `/debug` |
|
||||||
|
|
||||||
|
## Core Architecture
|
||||||
|
|
||||||
|
| Role | Agent | Responsibility |
|
||||||
|
|------|-------|----------------|
|
||||||
|
| **Orchestrator** | Claude Code | Planning, context gathering, verification |
|
||||||
|
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini/OpenCode) |
|
||||||
|
|
||||||
|
## Backend CLI Requirements
|
||||||
|
|
||||||
|
| Backend | Required Features |
|
||||||
|
|---------|-------------------|
|
||||||
|
| Codex | `codex e`, `--json`, `-C`, `resume` |
|
||||||
|
| Claude | `--output-format stream-json`, `-r` |
|
||||||
|
| Gemini | `-o stream-json`, `-y`, `-r` |
|
||||||
|
|
||||||
|
## Directory Structure After Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
~/.claude/
|
||||||
|
├── bin/codeagent-wrapper
|
||||||
|
├── CLAUDE.md
|
||||||
|
├── commands/
|
||||||
|
├── agents/
|
||||||
|
├── skills/
|
||||||
|
└── config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [codeagent-wrapper](codeagent-wrapper/README.md)
|
||||||
|
- [Plugin System](PLUGIN_README.md)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
|
||||||
|
**Codex wrapper not found:**
|
||||||
```bash
|
```bash
|
||||||
/plugin install bmad-agile-workflow
|
# Select: codeagent-wrapper
|
||||||
|
npx github:cexll/myclaude
|
||||||
```
|
```
|
||||||
|
|
||||||
**Method 2: Make Commands** (Selective installation)
|
**Module not loading:**
|
||||||
```bash
|
```bash
|
||||||
make deploy-bmad # BMAD workflow only
|
cat ~/.claude/installed_modules.json
|
||||||
make deploy-requirements # Requirements workflow only
|
npx github:cexll/myclaude --force
|
||||||
make deploy-all # Everything
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Method 3: Manual Setup**
|
**Backend CLI errors:**
|
||||||
- Copy `./commands/*.md` to `~/.config/claude/commands/`
|
```bash
|
||||||
- Copy `./agents/*.md` to `~/.config/claude/agents/`
|
which codex && codex --version
|
||||||
|
which claude && claude --version
|
||||||
|
which gemini && gemini --version
|
||||||
|
```
|
||||||
|
|
||||||
Run `make help` for all options.
|
## FAQ
|
||||||
|
|
||||||
## 📄 License
|
| Issue | Solution |
|
||||||
|
|-------|----------|
|
||||||
|
| "Unknown event format" | Logging display issue, can be ignored |
|
||||||
|
| Gemini can't read .gitignore files | Remove from .gitignore or use different backend |
|
||||||
|
| Codex permission denied | Set `approval_policy = "never"` in ~/.codex/config.yaml |
|
||||||
|
|
||||||
MIT License - see [LICENSE](LICENSE)
|
See [GitHub Issues](https://github.com/cexll/myclaude/issues) for more.
|
||||||
|
|
||||||
## 🙋 Support
|
## License
|
||||||
|
|
||||||
- **Issues**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
AGPL-3.0 - see [LICENSE](LICENSE)
|
||||||
- **Documentation**: [docs/](docs/)
|
|
||||||
- **Plugin Guide**: [PLUGIN_README.md](PLUGIN_README.md)
|
|
||||||
|
|
||||||
---
|
### Commercial Licensing
|
||||||
|
|
||||||
**Transform your development with AI-powered automation** - One command, complete workflow, quality assured.
|
For commercial use without AGPL obligations, contact: evanxian9@gmail.com
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||||
|
|||||||
332
README_CN.md
332
README_CN.md
@@ -1,122 +1,256 @@
|
|||||||
# Claude Code 多智能体工作流系统
|
# Claude Code 多智能体工作流系统
|
||||||
|
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||||
[](https://claude.ai/code)
|
[](https://claude.ai/code)
|
||||||
[](https://github.com/cexll/myclaude)
|
[](https://github.com/cexll/myclaude)
|
||||||
[](https://docs.claude.com/en/docs/claude-code/plugins)
|
|
||||||
|
|
||||||
> 企业级敏捷开发自动化与 AI 驱动的多智能体编排
|
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini/OpenCode)
|
||||||
|
|
||||||
[English](README.md) | [文档](docs/)
|
## 快速开始
|
||||||
|
|
||||||
## 🚀 快速开始
|
|
||||||
|
|
||||||
### 安装
|
|
||||||
|
|
||||||
**插件系统(推荐)**
|
|
||||||
```bash
|
|
||||||
/plugin marketplace add cexll/myclaude
|
|
||||||
```
|
|
||||||
|
|
||||||
**传统安装**
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/cexll/myclaude.git
|
|
||||||
cd myclaude
|
|
||||||
make install
|
|
||||||
```
|
|
||||||
|
|
||||||
### 基本使用
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 完整敏捷工作流
|
npx github:cexll/myclaude
|
||||||
/bmad-pilot "构建用户认证系统,支持 OAuth2 和多因素认证"
|
|
||||||
|
|
||||||
# 轻量级开发
|
|
||||||
/requirements-pilot "实现 JWT 令牌刷新"
|
|
||||||
|
|
||||||
# 直接开发命令
|
|
||||||
/code "添加 API 限流功能"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 📦 插件模块
|
## 模块概览
|
||||||
|
|
||||||
| 插件 | 描述 | 主要命令 |
|
| 模块 | 描述 | 文档 |
|
||||||
|------|------|---------|
|
|------|------|------|
|
||||||
| **[bmad-agile-workflow](docs/BMAD-WORKFLOW.md)** | 完整 BMAD 方法论,包含6个专业智能体 | `/bmad-pilot` |
|
| [do](skills/do/README.md) | **推荐** - 7 阶段功能开发 + codeagent 编排 | `/do` 命令 |
|
||||||
| **[requirements-driven-workflow](docs/REQUIREMENTS-WORKFLOW.md)** | 精简的需求到代码工作流 | `/requirements-pilot` |
|
| [omo](skills/omo/README.md) | 多智能体编排 + 智能路由 | `/omo` 命令 |
|
||||||
| **[dev-workflow](dev-workflow/README.md)** | 极简端到端开发工作流 | `/dev` |
|
| [bmad](agents/bmad/README.md) | BMAD 敏捷工作流 + 6 个专业智能体 | `/bmad-pilot` 命令 |
|
||||||
| **[development-essentials](docs/DEVELOPMENT-COMMANDS.md)** | 核心开发斜杠命令 | `/code` `/debug` `/test` `/optimize` |
|
| [requirements](agents/requirements/README.md) | 轻量级需求到代码流水线 | `/requirements-pilot` 命令 |
|
||||||
| **[advanced-ai-agents](docs/ADVANCED-AGENTS.md)** | GPT-5 深度推理集成 | 智能体: `gpt5` |
|
| [essentials](agents/development-essentials/README.md) | 核心开发命令和工具 | `/code`, `/debug` 等 |
|
||||||
| **[requirements-clarity](docs/REQUIREMENTS-CLARITY.md)** | 自动需求澄清,100分制质量评分 | 自动激活技能 |
|
| [sparv](skills/sparv/README.md) | SPARV 工作流 (Specify→Plan→Act→Review→Vault) | `/sparv` 命令 |
|
||||||
|
| course | 课程开发(组合 dev + product-requirements + test-cases) | 组合模块 |
|
||||||
|
|
||||||
## 💡 使用场景
|
## 核心架构
|
||||||
|
|
||||||
**BMAD 工作流** - 完整敏捷流程自动化
|
| 角色 | 智能体 | 职责 |
|
||||||
- 产品需求 → 架构设计 → 冲刺规划 → 开发实现 → 代码审查 → 质量测试
|
|------|-------|------|
|
||||||
- 90% 阈值质量门控
|
| **编排者** | Claude Code | 规划、上下文收集、验证 |
|
||||||
- 自动生成文档
|
| **执行者** | codeagent-wrapper | 代码编辑、测试执行(Codex/Claude/Gemini/OpenCode 后端)|
|
||||||
|
|
||||||
**Requirements 工作流** - 快速原型开发
|
## 工作流详解
|
||||||
- 需求生成 → 实现 → 审查 → 测试
|
|
||||||
- 轻量级实用主义
|
|
||||||
|
|
||||||
**开发命令** - 日常编码
|
### do 工作流(推荐)
|
||||||
- 直接实现、调试、测试、优化
|
|
||||||
- 无工作流开销
|
|
||||||
|
|
||||||
**需求澄清** - 自动化需求工程
|
7 阶段功能开发,通过 codeagent-wrapper 编排多个智能体。**大多数功能开发任务的首选工作流。**
|
||||||
- 自动检测模糊需求并启动澄清流程
|
|
||||||
- 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
|
```bash
|
||||||
/plugin install bmad-agile-workflow
|
/do "添加用户登录功能"
|
||||||
```
|
```
|
||||||
|
|
||||||
**方式2: Make 命令**(选择性安装)
|
**7 阶段:**
|
||||||
```bash
|
| 阶段 | 名称 | 目标 |
|
||||||
make deploy-bmad # 仅 BMAD 工作流
|
|------|------|------|
|
||||||
make deploy-requirements # 仅 Requirements 工作流
|
| 1 | Discovery | 理解需求 |
|
||||||
make deploy-all # 全部安装
|
| 2 | Exploration | 映射代码库模式 |
|
||||||
```
|
| 3 | Clarification | 解决歧义(**强制**)|
|
||||||
|
| 4 | Architecture | 设计实现方案 |
|
||||||
|
| 5 | Implementation | 构建功能(**需审批**)|
|
||||||
|
| 6 | Review | 捕获缺陷 |
|
||||||
|
| 7 | Summary | 记录结果 |
|
||||||
|
|
||||||
**方式3: 手动安装**
|
**智能体:**
|
||||||
- 复制 `./commands/*.md` 到 `~/.config/claude/commands/`
|
- `code-explorer` - 代码追踪、架构映射
|
||||||
- 复制 `./agents/*.md` 到 `~/.config/claude/agents/`
|
- `code-architect` - 设计方案、文件规划
|
||||||
|
- `code-reviewer` - 代码审查、简化建议
|
||||||
运行 `make help` 查看所有选项。
|
- `develop` - 实现代码、运行测试
|
||||||
|
|
||||||
## 📄 许可证
|
|
||||||
|
|
||||||
MIT 许可证 - 查看 [LICENSE](LICENSE)
|
|
||||||
|
|
||||||
## 🙋 支持
|
|
||||||
|
|
||||||
- **问题反馈**: [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
|
||||||
- **文档**: [docs/](docs/)
|
|
||||||
- **插件指南**: [PLUGIN_README.md](PLUGIN_README.md)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**使用 AI 驱动的自动化转型您的开发流程** - 一条命令,完整工作流,质量保证。
|
### OmO 多智能体编排器
|
||||||
|
|
||||||
|
基于风险信号智能路由任务到专业智能体。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/omo "分析并修复这个认证 bug"
|
||||||
|
```
|
||||||
|
|
||||||
|
**智能体层级:**
|
||||||
|
| 智能体 | 角色 | 后端 |
|
||||||
|
|-------|------|------|
|
||||||
|
| `oracle` | 技术顾问 | Claude |
|
||||||
|
| `librarian` | 外部研究 | Claude |
|
||||||
|
| `explore` | 代码库搜索 | OpenCode |
|
||||||
|
| `develop` | 代码实现 | Codex |
|
||||||
|
| `frontend-ui-ux-engineer` | UI/UX 专家 | Gemini |
|
||||||
|
| `document-writer` | 文档撰写 | Gemini |
|
||||||
|
|
||||||
|
**常用配方:**
|
||||||
|
- 解释代码:`explore`
|
||||||
|
- 位置已知的小修复:直接 `develop`
|
||||||
|
- Bug 修复(位置未知):`explore → develop`
|
||||||
|
- 跨模块重构:`explore → oracle → develop`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### SPARV 工作流
|
||||||
|
|
||||||
|
极简 5 阶段工作流:Specify → Plan → Act → Review → Vault。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/sparv "实现订单导出功能"
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心规则:**
|
||||||
|
- **10 分规格门**:得分 0-10,必须 >=9 才能进入 Plan
|
||||||
|
- **2 动作保存**:每 2 次工具调用写入 journal.md
|
||||||
|
- **3 失败协议**:连续 3 次失败后停止并上报
|
||||||
|
- **EHRB**:高风险操作需明确确认
|
||||||
|
|
||||||
|
**评分维度(各 0-2 分):**
|
||||||
|
1. Value - 为什么做,可验证的收益
|
||||||
|
2. Scope - MVP + 不在范围内的内容
|
||||||
|
3. Acceptance - 可测试的验收标准
|
||||||
|
4. Boundaries - 错误/性能/兼容/安全边界
|
||||||
|
5. Risk - EHRB/依赖/未知 + 处理方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### BMAD 敏捷工作流
|
||||||
|
|
||||||
|
完整企业敏捷方法论 + 6 个专业智能体。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bmad-pilot "构建电商结账系统"
|
||||||
|
```
|
||||||
|
|
||||||
|
**智能体角色:**
|
||||||
|
| 智能体 | 职责 |
|
||||||
|
|-------|------|
|
||||||
|
| Product Owner | 需求与用户故事 |
|
||||||
|
| Architect | 系统设计与技术决策 |
|
||||||
|
| Scrum Master | Sprint 规划与任务分解 |
|
||||||
|
| Developer | 实现 |
|
||||||
|
| Code Reviewer | 质量保证 |
|
||||||
|
| QA Engineer | 测试与验证 |
|
||||||
|
|
||||||
|
**审批门:**
|
||||||
|
- PRD 完成后(90+ 分)需用户审批
|
||||||
|
- 架构完成后(90+ 分)需用户审批
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 需求驱动工作流
|
||||||
|
|
||||||
|
轻量级需求到代码流水线。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/requirements-pilot "实现 API 限流"
|
||||||
|
```
|
||||||
|
|
||||||
|
**100 分质量评分:**
|
||||||
|
- 功能清晰度:30 分
|
||||||
|
- 技术具体性:25 分
|
||||||
|
- 实现完整性:25 分
|
||||||
|
- 业务上下文:20 分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 开发基础命令
|
||||||
|
|
||||||
|
日常编码任务的直接命令。
|
||||||
|
|
||||||
|
| 命令 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `/code` | 实现功能 |
|
||||||
|
| `/debug` | 调试问题 |
|
||||||
|
| `/test` | 编写测试 |
|
||||||
|
| `/review` | 代码审查 |
|
||||||
|
| `/optimize` | 性能优化 |
|
||||||
|
| `/refactor` | 代码重构 |
|
||||||
|
| `/docs` | 编写文档 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 交互式安装器(推荐)
|
||||||
|
npx github:cexll/myclaude
|
||||||
|
|
||||||
|
# 列出可安装项(module:* / skill:* / codeagent-wrapper)
|
||||||
|
npx github:cexll/myclaude --list
|
||||||
|
|
||||||
|
# 检测已安装 modules 并从 GitHub 更新
|
||||||
|
npx github:cexll/myclaude --update
|
||||||
|
|
||||||
|
# 指定安装目录 / 强制覆盖
|
||||||
|
npx github:cexll/myclaude --install-dir ~/.claude --force
|
||||||
|
```
|
||||||
|
|
||||||
|
`--update` 会在目标安装目录(默认 `~/.claude`,优先读取 `installed_modules.json`)检测已安装 modules,并从 GitHub 拉取最新发布版本覆盖更新。
|
||||||
|
|
||||||
|
### 模块配置
|
||||||
|
|
||||||
|
编辑 `config.json` 启用/禁用模块:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"modules": {
|
||||||
|
"bmad": { "enabled": false },
|
||||||
|
"requirements": { "enabled": false },
|
||||||
|
"essentials": { "enabled": false },
|
||||||
|
"omo": { "enabled": false },
|
||||||
|
"sparv": { "enabled": false },
|
||||||
|
"do": { "enabled": true },
|
||||||
|
"course": { "enabled": false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流选择指南
|
||||||
|
|
||||||
|
| 场景 | 推荐 |
|
||||||
|
|------|------|
|
||||||
|
| 功能开发(默认) | `/do` |
|
||||||
|
| Bug 调查 + 修复 | `/omo` |
|
||||||
|
| 大型企业项目 | `/bmad-pilot` |
|
||||||
|
| 快速原型 | `/requirements-pilot` |
|
||||||
|
| 简单任务 | `/code`, `/debug` |
|
||||||
|
|
||||||
|
## 后端 CLI 要求
|
||||||
|
|
||||||
|
| 后端 | 必需功能 |
|
||||||
|
|------|----------|
|
||||||
|
| Codex | `codex e`, `--json`, `-C`, `resume` |
|
||||||
|
| Claude | `--output-format stream-json`, `-r` |
|
||||||
|
| Gemini | `-o stream-json`, `-y`, `-r` |
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
**Codex wrapper 未找到:**
|
||||||
|
```bash
|
||||||
|
# 选择:codeagent-wrapper
|
||||||
|
npx github:cexll/myclaude
|
||||||
|
```
|
||||||
|
|
||||||
|
**模块未加载:**
|
||||||
|
```bash
|
||||||
|
cat ~/.claude/installed_modules.json
|
||||||
|
npx github:cexll/myclaude --force
|
||||||
|
```
|
||||||
|
|
||||||
|
## FAQ
|
||||||
|
|
||||||
|
| 问题 | 解决方案 |
|
||||||
|
|------|----------|
|
||||||
|
| "Unknown event format" | 日志显示问题,可忽略 |
|
||||||
|
| Gemini 无法读取 .gitignore 文件 | 从 .gitignore 移除或使用其他后端 |
|
||||||
|
| Codex 权限拒绝 | 在 ~/.codex/config.yaml 设置 `approval_policy = "never"` |
|
||||||
|
|
||||||
|
更多问题请访问 [GitHub Issues](https://github.com/cexll/myclaude/issues)。
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
AGPL-3.0 - 查看 [LICENSE](LICENSE)
|
||||||
|
|
||||||
|
### 商业授权
|
||||||
|
|
||||||
|
如需商业授权(无需遵守 AGPL 义务),请联系:evanxian9@gmail.com
|
||||||
|
|
||||||
|
## 支持
|
||||||
|
|
||||||
|
- [GitHub Issues](https://github.com/cexll/myclaude/issues)
|
||||||
|
|||||||
@@ -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.
|
|
||||||
9
agents/bmad/.claude-plugin/plugin.json
Normal file
9
agents/bmad/.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
109
agents/bmad/README.md
Normal file
109
agents/bmad/README.md
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
# bmad - BMAD Agile Workflow
|
||||||
|
|
||||||
|
Full enterprise agile methodology with 6 specialized agents, UltraThink analysis, and repository-aware development.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python install.py --module bmad
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/bmad-pilot <PROJECT_DESCRIPTION> [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `--skip-tests` | Skip QA testing phase |
|
||||||
|
| `--direct-dev` | Skip SM planning, go directly to development |
|
||||||
|
| `--skip-scan` | Skip initial repository scanning |
|
||||||
|
|
||||||
|
## Workflow Phases
|
||||||
|
|
||||||
|
| Phase | Agent | Deliverable | Description |
|
||||||
|
|-------|-------|-------------|-------------|
|
||||||
|
| 0 | Orchestrator | `00-repo-scan.md` | Repository scanning with UltraThink analysis |
|
||||||
|
| 1 | Product Owner (PO) | `01-product-requirements.md` | PRD with 90+ quality score required |
|
||||||
|
| 2 | Architect | `02-system-architecture.md` | Technical design with 90+ score required |
|
||||||
|
| 3 | Scrum Master (SM) | `03-sprint-plan.md` | Sprint backlog with stories and estimates |
|
||||||
|
| 4 | Developer | Implementation code | Multi-sprint implementation |
|
||||||
|
| 4.5 | Reviewer | `04-dev-reviewed.md` | Code review (Pass/Pass with Risk/Fail) |
|
||||||
|
| 5 | QA Engineer | Test suite | Comprehensive testing and validation |
|
||||||
|
|
||||||
|
## Agents
|
||||||
|
|
||||||
|
| Agent | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `bmad-orchestrator` | Repository scanning, workflow coordination |
|
||||||
|
| `bmad-po` | Requirements gathering, PRD creation |
|
||||||
|
| `bmad-architect` | System design, technology decisions |
|
||||||
|
| `bmad-sm` | Sprint planning, task breakdown |
|
||||||
|
| `bmad-dev` | Code implementation |
|
||||||
|
| `bmad-review` | Code review, quality assessment |
|
||||||
|
| `bmad-qa` | Testing, validation |
|
||||||
|
|
||||||
|
## Approval Gates
|
||||||
|
|
||||||
|
Two mandatory stop points require explicit user approval:
|
||||||
|
|
||||||
|
1. **After PRD** (Phase 1 → 2): User must approve requirements before architecture
|
||||||
|
2. **After Architecture** (Phase 2 → 3): User must approve design before implementation
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/specs/{feature_name}/
|
||||||
|
├── 00-repo-scan.md
|
||||||
|
├── 01-product-requirements.md
|
||||||
|
├── 02-system-architecture.md
|
||||||
|
├── 03-sprint-plan.md
|
||||||
|
└── 04-dev-reviewed.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## UltraThink Methodology
|
||||||
|
|
||||||
|
Applied throughout the workflow for deep analysis:
|
||||||
|
|
||||||
|
1. **Hypothesis Generation** - Form hypotheses about the problem
|
||||||
|
2. **Evidence Collection** - Gather evidence from codebase
|
||||||
|
3. **Pattern Recognition** - Identify recurring patterns
|
||||||
|
4. **Synthesis** - Create comprehensive understanding
|
||||||
|
5. **Validation** - Cross-check findings
|
||||||
|
|
||||||
|
## Interactive Confirmation Flow
|
||||||
|
|
||||||
|
PO and Architect phases use iterative refinement:
|
||||||
|
|
||||||
|
1. Agent produces initial draft + quality score
|
||||||
|
2. Orchestrator presents to user with clarification questions
|
||||||
|
3. User provides responses
|
||||||
|
4. Agent refines until quality >= 90
|
||||||
|
5. User confirms to save deliverable
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Large multi-sprint features
|
||||||
|
- Enterprise projects requiring documentation
|
||||||
|
- Team coordination scenarios
|
||||||
|
- Projects needing formal approval gates
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
agents/bmad/
|
||||||
|
├── README.md
|
||||||
|
├── commands/
|
||||||
|
│ └── bmad-pilot.md
|
||||||
|
└── agents/
|
||||||
|
├── bmad-orchestrator.md
|
||||||
|
├── bmad-po.md
|
||||||
|
├── bmad-architect.md
|
||||||
|
├── bmad-sm.md
|
||||||
|
├── bmad-dev.md
|
||||||
|
├── bmad-review.md
|
||||||
|
└── bmad-qa.md
|
||||||
|
```
|
||||||
@@ -427,6 +427,10 @@ Generate architecture document at `./.claude/specs/{feature_name}/02-system-arch
|
|||||||
|
|
||||||
## Important Behaviors
|
## 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:
|
### DO:
|
||||||
- Start by reviewing and referencing the PRD
|
- Start by reviewing and referencing the PRD
|
||||||
- Present initial architecture based on requirements
|
- Present initial architecture based on requirements
|
||||||
@@ -419,6 +419,10 @@ logger.info('User created', {
|
|||||||
|
|
||||||
## Important Implementation Rules
|
## 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:
|
### DO:
|
||||||
- Follow architecture specifications exactly
|
- Follow architecture specifications exactly
|
||||||
- Implement all acceptance criteria from PRD
|
- 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
|
- Consistency: ensure conventions and patterns discovered in scan are preserved downstream
|
||||||
- Explicit handoffs: clearly document assumptions, risks, and integration points for other agents
|
- 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
|
## UltraThink Repository Scan
|
||||||
|
|
||||||
When asked to analyze the repository, follow this structure and return a clear, actionable summary.
|
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
|
## 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:
|
### DO:
|
||||||
- Start immediately with greeting and initial understanding
|
- Start immediately with greeting and initial understanding
|
||||||
- Show quality scores transparently
|
- Show quality scores transparently
|
||||||
@@ -478,6 +478,10 @@ module.exports = {
|
|||||||
|
|
||||||
## Important Testing Rules
|
## 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:
|
### DO:
|
||||||
- Test all acceptance criteria from PRD
|
- Test all acceptance criteria from PRD
|
||||||
- Cover happy path, edge cases, and error scenarios
|
- 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
|
- Focus on actionable findings
|
||||||
- Provide specific QA guidance
|
- Provide specific QA guidance
|
||||||
- Use clear, parseable output format
|
- 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
|
## 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:
|
### DO:
|
||||||
- Read both PRD and Architecture documents thoroughly
|
- Read both PRD and Architecture documents thoroughly
|
||||||
- Create comprehensive task breakdown
|
- Create comprehensive task breakdown
|
||||||
9
agents/development-essentials/.claude-plugin/plugin.json
Normal file
9
agents/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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -304,7 +304,7 @@ Deep reasoning and analysis for complex problems.
|
|||||||
## 🔌 Agent Configuration
|
## 🔌 Agent Configuration
|
||||||
|
|
||||||
All commands use specialized agents configured in:
|
All commands use specialized agents configured in:
|
||||||
- `development-essentials/agents/`
|
- `agents/development-essentials/agents/`
|
||||||
- Agent prompt templates
|
- Agent prompt templates
|
||||||
- Tool access permissions
|
- Tool access permissions
|
||||||
- Output formatting
|
- Output formatting
|
||||||
@@ -244,8 +244,8 @@ Development Essentials 模块包含以下专用代理:
|
|||||||
## 🔗 相关文档
|
## 🔗 相关文档
|
||||||
|
|
||||||
- [主文档](../README.md) - 项目总览
|
- [主文档](../README.md) - 项目总览
|
||||||
- [BMAD工作流](../docs/BMAD-WORKFLOW.md) - 完整敏捷流程
|
- [BMAD工作流](../agents/bmad/BMAD-WORKFLOW.md) - 完整敏捷流程
|
||||||
- [Requirements工作流](../docs/REQUIREMENTS-WORKFLOW.md) - 轻量级开发流程
|
- [Requirements工作流](../agents/requirements/REQUIREMENTS-WORKFLOW.md) - 轻量级开发流程
|
||||||
- [插件系统](../PLUGIN_README.md) - 插件安装和管理
|
- [插件系统](../PLUGIN_README.md) - 插件安装和管理
|
||||||
|
|
||||||
---
|
---
|
||||||
9
agents/requirements/.claude-plugin/plugin.json
Normal file
9
agents/requirements/.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
90
agents/requirements/README.md
Normal file
90
agents/requirements/README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# requirements - Requirements-Driven Workflow
|
||||||
|
|
||||||
|
Lightweight requirements-to-code pipeline with interactive quality gates.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python install.py --module requirements
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/requirements-pilot <FEATURE_DESCRIPTION> [OPTIONS]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Options
|
||||||
|
|
||||||
|
| Option | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `--skip-tests` | Skip testing phase entirely |
|
||||||
|
| `--skip-scan` | Skip initial repository scanning |
|
||||||
|
|
||||||
|
## Workflow Phases
|
||||||
|
|
||||||
|
| Phase | Description | Output |
|
||||||
|
|-------|-------------|--------|
|
||||||
|
| 0 | Repository scanning | `00-repository-context.md` |
|
||||||
|
| 1 | Requirements confirmation | `requirements-confirm.md` (90+ score required) |
|
||||||
|
| 2 | Implementation | Code + `requirements-spec.md` |
|
||||||
|
|
||||||
|
## Quality Scoring (100-point system)
|
||||||
|
|
||||||
|
| Category | Points | Focus |
|
||||||
|
|----------|--------|-------|
|
||||||
|
| Functional Clarity | 30 | Input/output specs, success criteria |
|
||||||
|
| Technical Specificity | 25 | Integration points, constraints |
|
||||||
|
| Implementation Completeness | 25 | Edge cases, error handling |
|
||||||
|
| Business Context | 20 | User value, priority |
|
||||||
|
|
||||||
|
## Sub-Agents
|
||||||
|
|
||||||
|
| Agent | Role |
|
||||||
|
|-------|------|
|
||||||
|
| `requirements-generate` | Create technical specifications |
|
||||||
|
| `requirements-code` | Implement functionality |
|
||||||
|
| `requirements-review` | Code quality evaluation |
|
||||||
|
| `requirements-testing` | Test case creation |
|
||||||
|
|
||||||
|
## Approval Gate
|
||||||
|
|
||||||
|
One mandatory stop point after Phase 1:
|
||||||
|
- Requirements must achieve 90+ quality score
|
||||||
|
- User must explicitly approve before implementation begins
|
||||||
|
|
||||||
|
## Testing Decision
|
||||||
|
|
||||||
|
After code review passes (≥90%):
|
||||||
|
- `--skip-tests`: Complete without testing
|
||||||
|
- No option: Interactive prompt with smart recommendations based on task complexity
|
||||||
|
|
||||||
|
## Output Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.claude/specs/{feature_name}/
|
||||||
|
├── 00-repository-context.md
|
||||||
|
├── requirements-confirm.md
|
||||||
|
└── requirements-spec.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## When to Use
|
||||||
|
|
||||||
|
- Quick prototypes
|
||||||
|
- Well-defined features
|
||||||
|
- Smaller scope tasks
|
||||||
|
- When full BMAD workflow is overkill
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
agents/requirements/
|
||||||
|
├── README.md
|
||||||
|
├── commands/
|
||||||
|
│ └── requirements-pilot.md
|
||||||
|
└── agents/
|
||||||
|
├── requirements-generate.md
|
||||||
|
├── requirements-code.md
|
||||||
|
├── requirements-review.md
|
||||||
|
└── requirements-testing.md
|
||||||
|
```
|
||||||
@@ -104,6 +104,10 @@ You adhere to core software engineering principles like KISS (Keep It Simple, St
|
|||||||
|
|
||||||
## Implementation Constraints
|
## 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
|
### MUST Requirements
|
||||||
- **Working Solution**: Code must fully implement the specified functionality
|
- **Working Solution**: Code must fully implement the specified functionality
|
||||||
- **Integration Compatibility**: Must work seamlessly with existing codebase
|
- **Integration Compatibility**: Must work seamlessly with existing codebase
|
||||||
@@ -88,6 +88,10 @@ Each phase should be independently deployable and testable.
|
|||||||
|
|
||||||
## Key Constraints
|
## 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
|
### MUST Requirements
|
||||||
- **Direct Implementability**: Every item must be directly translatable to code
|
- **Direct Implementability**: Every item must be directly translatable to code
|
||||||
- **Specific Technical Details**: Include exact file paths, function names, table schemas
|
- **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
|
## 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
|
### MUST Requirements
|
||||||
- **Functional Verification**: Verify all specified functionality works
|
- **Functional Verification**: Verify all specified functionality works
|
||||||
- **Integration Testing**: Ensure seamless integration with existing code
|
- **Integration Testing**: Ensure seamless integration with existing code
|
||||||
@@ -199,6 +199,10 @@ func TestAPIEndpoint(t *testing.T) {
|
|||||||
|
|
||||||
## Key Constraints
|
## 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
|
### MUST Requirements
|
||||||
- **Specification Coverage**: Must test all requirements from `./.claude/specs/{feature_name}/requirements-spec.md`
|
- **Specification Coverage**: Must test all requirements from `./.claude/specs/{feature_name}/requirements-spec.md`
|
||||||
- **Critical Path Testing**: Must test all critical business functionality
|
- **Critical Path Testing**: Must test all critical business functionality
|
||||||
1125
bin/cli.js
Executable file
1125
bin/cli.js
Executable file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
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"
|
||||||
47
codeagent-wrapper/.github/workflows/ci.yml
vendored
Normal file
47
codeagent-wrapper/.github/workflows/ci.yml
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
go-version: ["1.21", "1.22"]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go-version }}
|
||||||
|
cache: true
|
||||||
|
- name: Test
|
||||||
|
run: make test
|
||||||
|
- name: Build
|
||||||
|
run: make build
|
||||||
|
- name: Verify version
|
||||||
|
run: ./codeagent-wrapper --version
|
||||||
|
|
||||||
|
lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
fetch-tags: true
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.22"
|
||||||
|
cache: true
|
||||||
|
- name: Lint
|
||||||
|
run: make lint
|
||||||
|
|
||||||
23
codeagent-wrapper/.gitignore
vendored
Normal file
23
codeagent-wrapper/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Build artifacts
|
||||||
|
bin/
|
||||||
|
codeagent
|
||||||
|
codeagent.exe
|
||||||
|
/codeagent-wrapper
|
||||||
|
/codeagent-wrapper.exe
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Coverage reports
|
||||||
|
coverage.out
|
||||||
|
coverage*.out
|
||||||
|
cover.out
|
||||||
|
cover_*.out
|
||||||
|
coverage.html
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Temp files
|
||||||
|
*.tmp
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
37
codeagent-wrapper/Makefile
Normal file
37
codeagent-wrapper/Makefile
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
GO ?= go
|
||||||
|
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||||
|
LDFLAGS := -ldflags "-X codeagent-wrapper/internal/app.version=$(VERSION)"
|
||||||
|
|
||||||
|
TOOLS_BIN := $(CURDIR)/bin
|
||||||
|
TOOLCHAIN ?= go1.22.0
|
||||||
|
GOLANGCI_LINT_VERSION := v1.56.2
|
||||||
|
STATICCHECK_VERSION := v0.4.7
|
||||||
|
|
||||||
|
GOLANGCI_LINT := $(TOOLS_BIN)/golangci-lint
|
||||||
|
STATICCHECK := $(TOOLS_BIN)/staticcheck
|
||||||
|
|
||||||
|
.PHONY: build test lint clean install
|
||||||
|
|
||||||
|
build:
|
||||||
|
$(GO) build $(LDFLAGS) -o codeagent-wrapper ./cmd/codeagent-wrapper
|
||||||
|
|
||||||
|
test:
|
||||||
|
$(GO) test ./...
|
||||||
|
|
||||||
|
$(GOLANGCI_LINT):
|
||||||
|
@mkdir -p $(TOOLS_BIN)
|
||||||
|
GOTOOLCHAIN=$(TOOLCHAIN) GOBIN=$(TOOLS_BIN) $(GO) install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
|
||||||
|
|
||||||
|
$(STATICCHECK):
|
||||||
|
@mkdir -p $(TOOLS_BIN)
|
||||||
|
GOTOOLCHAIN=$(TOOLCHAIN) GOBIN=$(TOOLS_BIN) $(GO) install honnef.co/go/tools/cmd/staticcheck@$(STATICCHECK_VERSION)
|
||||||
|
|
||||||
|
lint: $(GOLANGCI_LINT) $(STATICCHECK)
|
||||||
|
GOTOOLCHAIN=$(TOOLCHAIN) $(GOLANGCI_LINT) run ./...
|
||||||
|
GOTOOLCHAIN=$(TOOLCHAIN) $(STATICCHECK) ./...
|
||||||
|
|
||||||
|
clean:
|
||||||
|
@python3 -c 'import glob, os; paths=["codeagent","codeagent.exe","codeagent-wrapper","codeagent-wrapper.exe","coverage.out","cover.out","coverage.html"]; paths += glob.glob("coverage*.out") + glob.glob("cover_*.out") + glob.glob("*.test"); [os.remove(p) for p in paths if os.path.exists(p)]'
|
||||||
|
|
||||||
|
install:
|
||||||
|
$(GO) install $(LDFLAGS) ./cmd/codeagent-wrapper
|
||||||
157
codeagent-wrapper/README.md
Normal file
157
codeagent-wrapper/README.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# codeagent-wrapper
|
||||||
|
|
||||||
|
`codeagent-wrapper` 是一个用 Go 编写的“多后端 AI 代码代理”命令行包装器:用统一的 CLI 入口封装不同的 AI 工具后端(Codex / Claude / Gemini / Opencode),并提供一致的参数、配置与会话恢复体验。
|
||||||
|
|
||||||
|
入口:`cmd/codeagent/main.go`(生成二进制名:`codeagent`)和 `cmd/codeagent-wrapper/main.go`(生成二进制名:`codeagent-wrapper`)。两者行为一致。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- 多后端支持:`codex` / `claude` / `gemini` / `opencode`
|
||||||
|
- 统一命令行:`codeagent [flags] <task>` / `codeagent resume <session_id> <task> [workdir]`
|
||||||
|
- 自动 stdin:遇到换行/特殊字符/超长任务自动走 stdin,避免 shell quoting 地狱;也可显式使用 `-`
|
||||||
|
- 配置合并:支持配置文件与 `CODEAGENT_*` 环境变量(viper)
|
||||||
|
- Agent 预设:从 `~/.codeagent/models.json` 读取 backend/model/prompt 等预设
|
||||||
|
- 并行执行:`--parallel` 从 stdin 读取多任务配置,支持依赖拓扑并发执行
|
||||||
|
- 日志清理:`codeagent cleanup` 清理旧日志(日志写入系统临时目录)
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
要求:Go 1.21+。
|
||||||
|
|
||||||
|
在仓库根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go install ./cmd/codeagent
|
||||||
|
go install ./cmd/codeagent-wrapper
|
||||||
|
```
|
||||||
|
|
||||||
|
安装后确认:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeagent version
|
||||||
|
codeagent-wrapper version
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
最简单用法(默认后端:`codex`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeagent "分析 internal/app/cli.go 的入口逻辑,给出改进建议"
|
||||||
|
```
|
||||||
|
|
||||||
|
指定后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeagent --backend claude "解释 internal/executor/parallel_config.go 的并行配置格式"
|
||||||
|
```
|
||||||
|
|
||||||
|
指定工作目录(第 2 个位置参数):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeagent "在当前 repo 下搜索潜在数据竞争" .
|
||||||
|
```
|
||||||
|
|
||||||
|
显式从 stdin 读取 task(使用 `-`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat task.txt | codeagent -
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复会话:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeagent resume <session_id> "继续上次任务"
|
||||||
|
```
|
||||||
|
|
||||||
|
并行模式(从 stdin 读取任务配置;禁止位置参数):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
codeagent --parallel <<'EOF'
|
||||||
|
---TASK---
|
||||||
|
id: t1
|
||||||
|
workdir: .
|
||||||
|
backend: codex
|
||||||
|
---CONTENT---
|
||||||
|
列出本项目的主要模块以及它们的职责。
|
||||||
|
---TASK---
|
||||||
|
id: t2
|
||||||
|
dependencies: t1
|
||||||
|
backend: claude
|
||||||
|
---CONTENT---
|
||||||
|
基于 t1 的结论,提出重构风险点与建议。
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 配置文件
|
||||||
|
|
||||||
|
默认查找路径(当 `--config` 为空时):
|
||||||
|
|
||||||
|
- `$HOME/.codeagent/config.(yaml|yml|json|toml|...)`
|
||||||
|
|
||||||
|
示例(YAML):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
backend: codex
|
||||||
|
model: gpt-4.1
|
||||||
|
skip-permissions: false
|
||||||
|
```
|
||||||
|
|
||||||
|
也可以通过 `--config /path/to/config.yaml` 显式指定。
|
||||||
|
|
||||||
|
### 环境变量(`CODEAGENT_*`)
|
||||||
|
|
||||||
|
通过 viper 读取并自动映射 `-` 为 `_`,常用项:
|
||||||
|
|
||||||
|
- `CODEAGENT_BACKEND`(`codex|claude|gemini|opencode`)
|
||||||
|
- `CODEAGENT_MODEL`
|
||||||
|
- `CODEAGENT_AGENT`
|
||||||
|
- `CODEAGENT_PROMPT_FILE`
|
||||||
|
- `CODEAGENT_REASONING_EFFORT`
|
||||||
|
- `CODEAGENT_SKIP_PERMISSIONS`
|
||||||
|
- `CODEAGENT_FULL_OUTPUT`(并行模式 legacy 输出)
|
||||||
|
- `CODEAGENT_MAX_PARALLEL_WORKERS`(0 表示不限制,上限 100)
|
||||||
|
|
||||||
|
### Agent 预设(`~/.codeagent/models.json`)
|
||||||
|
|
||||||
|
可在 `~/.codeagent/models.json` 定义 agent → backend/model/prompt 等映射,用 `--agent <name>` 选择:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"default_backend": "opencode",
|
||||||
|
"default_model": "opencode/grok-code",
|
||||||
|
"agents": {
|
||||||
|
"develop": {
|
||||||
|
"backend": "codex",
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"prompt_file": "~/.codeagent/prompts/develop.md",
|
||||||
|
"description": "Code development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 支持的后端
|
||||||
|
|
||||||
|
该项目本身不内置模型能力,依赖你本机安装并可在 `PATH` 中找到对应 CLI:
|
||||||
|
|
||||||
|
- `codex`:执行 `codex e ...`(默认会添加 `--dangerously-bypass-approvals-and-sandbox`;如需关闭请设置 `CODEX_BYPASS_SANDBOX=false`)
|
||||||
|
- `claude`:执行 `claude -p ... --output-format stream-json`(默认会跳过权限提示;如需开启请设置 `CODEAGENT_SKIP_PERMISSIONS=false`)
|
||||||
|
- `gemini`:执行 `gemini ... -o stream-json`(可从 `~/.gemini/.env` 加载环境变量)
|
||||||
|
- `opencode`:执行 `opencode run --format json`
|
||||||
|
|
||||||
|
## 开发
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make build
|
||||||
|
make test
|
||||||
|
make lint
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
- macOS 下如果看到临时目录相关的 `permission denied`(例如临时可执行文件无法在 `/var/folders/.../T` 执行),可设置一个可执行的临时目录:`CODEAGENT_TMPDIR=$HOME/.codeagent/tmp`。
|
||||||
|
- `claude` 后端的 `base_url/api_key`(来自 `~/.codeagent/models.json`)会注入到子进程环境变量:`ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`。若 `base_url` 指向本地代理(如 `localhost:23001`),请确认代理进程在运行。
|
||||||
447
codeagent-wrapper/USER_GUIDE.md
Normal file
447
codeagent-wrapper/USER_GUIDE.md
Normal file
@@ -0,0 +1,447 @@
|
|||||||
|
# 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
|
||||||
|
# Recommended: run the installer and select "codeagent-wrapper"
|
||||||
|
npx github:cexll/myclaude
|
||||||
|
|
||||||
|
# Manual build (optional; requires repo checkout)
|
||||||
|
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)
|
||||||
7
codeagent-wrapper/cmd/codeagent-wrapper/main.go
Normal file
7
codeagent-wrapper/cmd/codeagent-wrapper/main.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import app "codeagent-wrapper/internal/app"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
app.Run()
|
||||||
|
}
|
||||||
43
codeagent-wrapper/go.mod
Normal file
43
codeagent-wrapper/go.mod
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
module codeagent-wrapper
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/goccy/go-json v0.10.5
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5
|
||||||
|
github.com/spf13/cobra v1.8.1
|
||||||
|
github.com/spf13/pflag v1.0.5
|
||||||
|
github.com/spf13/viper v1.19.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
github.com/spf13/afero v1.11.0 // indirect
|
||||||
|
github.com/spf13/cast v1.6.0 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
go.uber.org/atomic v1.9.0 // indirect
|
||||||
|
go.uber.org/multierr v1.9.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
golang.org/x/text v0.14.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
117
codeagent-wrapper/go.sum
Normal file
117
codeagent-wrapper/go.sum
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||||
|
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||||
|
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||||
|
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||||
|
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
|
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||||
|
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||||
|
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||||
|
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
|
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||||
|
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||||
|
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||||
|
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
||||||
|
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
|
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||||
|
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||||
|
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||||
|
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
150
codeagent-wrapper/internal/app/agent_validation_test.go
Normal file
150
codeagent-wrapper/internal/app/agent_validation_test.go
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
executor "codeagent-wrapper/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
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 := config.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)
|
||||||
|
t.Cleanup(config.ResetModelsConfigCacheForTest)
|
||||||
|
config.ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
|
||||||
|
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
||||||
|
_ = executor.SetSelectBackendFn(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
279
codeagent-wrapper/internal/app/app.go
Normal file
279
codeagent-wrapper/internal/app/app.go
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
const (
|
||||||
|
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 = 500 * time.Millisecond
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test hooks for dependency injection
|
||||||
|
var (
|
||||||
|
stdinReader io.Reader = os.Stdin
|
||||||
|
isTerminalFn = defaultIsTerminal
|
||||||
|
codexCommand = defaultCodexCommand
|
||||||
|
cleanupHook func()
|
||||||
|
startupCleanupAsync = true
|
||||||
|
|
||||||
|
buildCodexArgsFn = buildCodexArgs
|
||||||
|
selectBackendFn = selectBackend
|
||||||
|
cleanupLogsFn = cleanupOldLogs
|
||||||
|
defaultBuildArgsFn = buildCodexArgs
|
||||||
|
runTaskFn = runCodexTask
|
||||||
|
exitFn = os.Exit
|
||||||
|
)
|
||||||
|
|
||||||
|
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 scheduleStartupCleanup() {
|
||||||
|
if !startupCleanupAsync {
|
||||||
|
runStartupCleanup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cleanupLogsFn == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn := cleanupLogsFn
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if _, err := fn(); 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 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 {
|
||||||
|
allowedDirs := []string{
|
||||||
|
filepath.Clean(filepath.Join(home, ".claude")),
|
||||||
|
filepath.Clean(filepath.Join(home, ".codeagent", "agents")),
|
||||||
|
}
|
||||||
|
for i := range allowedDirs {
|
||||||
|
allowedAbs, err := filepath.Abs(allowedDirs[i])
|
||||||
|
if err == nil {
|
||||||
|
allowedDirs[i] = 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 {
|
||||||
|
withinAllowed := false
|
||||||
|
for _, dir := range allowedDirs {
|
||||||
|
if isWithinDir(absPath, dir) {
|
||||||
|
withinAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !withinAllowed {
|
||||||
|
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), absPath))
|
||||||
|
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvedPath, errPath := filepath.EvalSymlinks(absPath)
|
||||||
|
if errPath == nil {
|
||||||
|
resolvedPath = filepath.Clean(resolvedPath)
|
||||||
|
resolvedAllowed := make([]string, 0, len(allowedDirs))
|
||||||
|
for _, dir := range allowedDirs {
|
||||||
|
resolvedBase, errBase := filepath.EvalSymlinks(dir)
|
||||||
|
if errBase != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resolvedAllowed = append(resolvedAllowed, filepath.Clean(resolvedBase))
|
||||||
|
}
|
||||||
|
if len(resolvedAllowed) > 0 {
|
||||||
|
withinResolved := false
|
||||||
|
for _, dir := range resolvedAllowed {
|
||||||
|
if isWithinDir(resolvedPath, dir) {
|
||||||
|
withinResolved = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !withinResolved {
|
||||||
|
logWarn(fmt.Sprintf("Refusing to read prompt file outside allowed dirs (%s) (resolved): %s", strings.Join(resolvedAllowed, ", "), resolvedPath))
|
||||||
|
return "", fmt.Errorf("prompt file must be under ~/.claude or ~/.codeagent/agents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
withinAllowed := false
|
||||||
|
for _, dir := range allowedDirs {
|
||||||
|
if isWithinDir(absPath, dir) {
|
||||||
|
withinAllowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !withinAllowed {
|
||||||
|
logWarn(fmt.Sprintf("Reading prompt file outside allowed dirs (%s): %s", strings.Join(allowedDirs, ", "), 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 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)
|
||||||
|
}
|
||||||
9
codeagent-wrapper/internal/app/backend.go
Normal file
9
codeagent-wrapper/internal/app/backend.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import backend "codeagent-wrapper/internal/backend"
|
||||||
|
|
||||||
|
type Backend = backend.Backend
|
||||||
|
type CodexBackend = backend.CodexBackend
|
||||||
|
type ClaudeBackend = backend.ClaudeBackend
|
||||||
|
type GeminiBackend = backend.GeminiBackend
|
||||||
|
type OpencodeBackend = backend.OpencodeBackend
|
||||||
7
codeagent-wrapper/internal/app/backend_init.go
Normal file
7
codeagent-wrapper/internal/app/backend_init.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import backend "codeagent-wrapper/internal/backend"
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
backend.SetLogFuncs(logWarn, logError)
|
||||||
|
}
|
||||||
5
codeagent-wrapper/internal/app/backend_registry.go
Normal file
5
codeagent-wrapper/internal/app/backend_registry.go
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import backend "codeagent-wrapper/internal/backend"
|
||||||
|
|
||||||
|
func selectBackend(name string) (Backend, error) { return backend.Select(name) }
|
||||||
116
codeagent-wrapper/internal/app/bench_test.go
Normal file
116
codeagent-wrapper/internal/app/bench_test.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
benchCmdSink any
|
||||||
|
benchConfigSink *Config
|
||||||
|
benchMessageSink string
|
||||||
|
benchThreadIDSink string
|
||||||
|
)
|
||||||
|
|
||||||
|
// BenchmarkStartup_NewRootCommand measures CLI startup overhead (command+flags construction).
|
||||||
|
func BenchmarkStartup_NewRootCommand(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
benchCmdSink = newRootCommand()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkConfigParse_ParseArgs measures config parsing from argv/env (steady-state).
|
||||||
|
func BenchmarkConfigParse_ParseArgs(b *testing.B) {
|
||||||
|
home := b.TempDir()
|
||||||
|
b.Setenv("HOME", home)
|
||||||
|
b.Setenv("USERPROFILE", home)
|
||||||
|
|
||||||
|
configDir := filepath.Join(home, ".codeagent")
|
||||||
|
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||||
|
"agents": {
|
||||||
|
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||||
|
}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.ResetModelsConfigCacheForTest()
|
||||||
|
b.Cleanup(config.ResetModelsConfigCacheForTest)
|
||||||
|
|
||||||
|
origArgs := os.Args
|
||||||
|
os.Args = []string{"codeagent-wrapper", "--agent", "develop", "task"}
|
||||||
|
b.Cleanup(func() { os.Args = origArgs })
|
||||||
|
|
||||||
|
if _, err := parseArgs(); err != nil {
|
||||||
|
b.Fatalf("warmup parseArgs() error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
cfg, err := parseArgs()
|
||||||
|
if err != nil {
|
||||||
|
b.Fatalf("parseArgs() error: %v", err)
|
||||||
|
}
|
||||||
|
benchConfigSink = cfg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BenchmarkJSONParse_ParseJSONStreamInternal measures line-delimited JSON stream parsing.
|
||||||
|
func BenchmarkJSONParse_ParseJSONStreamInternal(b *testing.B) {
|
||||||
|
stream := []byte(
|
||||||
|
`{"type":"thread.started","thread_id":"t"}` + "\n" +
|
||||||
|
`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}` + "\n" +
|
||||||
|
`{"type":"thread.completed","thread_id":"t"}` + "\n",
|
||||||
|
)
|
||||||
|
b.SetBytes(int64(len(stream)))
|
||||||
|
|
||||||
|
b.ReportAllocs()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
message, threadID := parseJSONStreamInternal(bytes.NewReader(stream), nil, nil, nil, nil)
|
||||||
|
benchMessageSink = message
|
||||||
|
benchThreadIDSink = threadID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
}
|
||||||
683
codeagent-wrapper/internal/app/cli.go
Normal file
683
codeagent-wrapper/internal/app/cli.go
Normal file
@@ -0,0 +1,683 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/spf13/pflag"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
type exitError struct {
|
||||||
|
code int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e exitError) Error() string {
|
||||||
|
return fmt.Sprintf("exit %d", e.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
type cliOptions struct {
|
||||||
|
Backend string
|
||||||
|
Model string
|
||||||
|
ReasoningEffort string
|
||||||
|
Agent string
|
||||||
|
PromptFile string
|
||||||
|
SkipPermissions bool
|
||||||
|
Worktree bool
|
||||||
|
|
||||||
|
Parallel bool
|
||||||
|
FullOutput bool
|
||||||
|
|
||||||
|
Cleanup bool
|
||||||
|
Version bool
|
||||||
|
ConfigFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Main() {
|
||||||
|
Run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run is the program entrypoint for cmd/codeagent/main.go.
|
||||||
|
func Run() {
|
||||||
|
exitFn(run())
|
||||||
|
}
|
||||||
|
|
||||||
|
func run() int {
|
||||||
|
cmd := newRootCommand()
|
||||||
|
cmd.SetArgs(os.Args[1:])
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
var ee exitError
|
||||||
|
if errors.As(err, &ee) {
|
||||||
|
return ee.code
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRootCommand() *cobra.Command {
|
||||||
|
name := currentWrapperName()
|
||||||
|
opts := &cliOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: fmt.Sprintf("%s [flags] <task>|resume <session_id> <task> [workdir]", name),
|
||||||
|
Short: "Go wrapper for AI CLI backends",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
Args: cobra.ArbitraryArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if opts.Version {
|
||||||
|
fmt.Printf("%s version %s\n", name, version)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if opts.Cleanup {
|
||||||
|
code := runCleanupMode()
|
||||||
|
if code == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return exitError{code: code}
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode := runWithLoggerAndCleanup(func() int {
|
||||||
|
v, err := config.NewViper(opts.ConfigFile)
|
||||||
|
if err != nil {
|
||||||
|
logError(err.Error())
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.Parallel {
|
||||||
|
return runParallelMode(cmd, args, opts, v, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
logInfo("Script started")
|
||||||
|
|
||||||
|
cfg, err := buildSingleConfig(cmd, args, os.Args[1:], opts, v)
|
||||||
|
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))
|
||||||
|
return runSingleMode(cfg, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
if exitCode == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return exitError{code: exitCode}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.CompletionOptions.DisableDefaultCmd = true
|
||||||
|
|
||||||
|
addRootFlags(cmd.Flags(), opts)
|
||||||
|
cmd.AddCommand(newVersionCommand(name), newCleanupCommand())
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) {
|
||||||
|
fs.StringVar(&opts.ConfigFile, "config", "", "Config file path (default: $HOME/.codeagent/config.*)")
|
||||||
|
fs.BoolVarP(&opts.Version, "version", "v", false, "Print version and exit")
|
||||||
|
fs.BoolVar(&opts.Cleanup, "cleanup", false, "Clean up old logs and exit")
|
||||||
|
|
||||||
|
fs.BoolVar(&opts.Parallel, "parallel", false, "Run tasks in parallel (config from stdin)")
|
||||||
|
fs.BoolVar(&opts.FullOutput, "full-output", false, "Parallel mode: include full task output (legacy)")
|
||||||
|
|
||||||
|
fs.StringVar(&opts.Backend, "backend", defaultBackendName, "Backend to use (codex, claude, gemini, opencode)")
|
||||||
|
fs.StringVar(&opts.Model, "model", "", "Model override")
|
||||||
|
fs.StringVar(&opts.ReasoningEffort, "reasoning-effort", "", "Reasoning effort (backend-specific)")
|
||||||
|
fs.StringVar(&opts.Agent, "agent", "", "Agent preset name (from ~/.codeagent/models.json)")
|
||||||
|
fs.StringVar(&opts.PromptFile, "prompt-file", "", "Prompt file path")
|
||||||
|
|
||||||
|
fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)")
|
||||||
|
fs.BoolVar(&opts.SkipPermissions, "dangerously-skip-permissions", false, "Alias for --skip-permissions")
|
||||||
|
fs.BoolVar(&opts.Worktree, "worktree", false, "Execute in a new git worktree (auto-generates task ID)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newVersionCommand(name string) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print version and exit",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
fmt.Printf("%s version %s\n", name, version)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCleanupCommand() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "cleanup",
|
||||||
|
Short: "Clean up old logs and exit",
|
||||||
|
SilenceErrors: true,
|
||||||
|
SilenceUsage: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
code := runCleanupMode()
|
||||||
|
if code == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return exitError{code: code}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWithLoggerAndCleanup(fn func() int) (exitCode int) {
|
||||||
|
ensureExecutableTempDir()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if exitCode != 0 {
|
||||||
|
if entries := logger.ExtractRecentErrors(10); len(entries) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "\n=== Recent Errors ===")
|
||||||
|
for _, entry := range entries {
|
||||||
|
fmt.Fprintln(os.Stderr, entry)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stderr, "Log file: %s (deleted)\n", logger.Path())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = logger.RemoveLogFile()
|
||||||
|
}()
|
||||||
|
defer runCleanupHook()
|
||||||
|
|
||||||
|
// Clean up stale logs from previous runs.
|
||||||
|
scheduleStartupCleanup()
|
||||||
|
|
||||||
|
return fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseArgs() (*Config, error) {
|
||||||
|
opts := &cliOptions{}
|
||||||
|
cmd := &cobra.Command{SilenceErrors: true, SilenceUsage: true, Args: cobra.ArbitraryArgs}
|
||||||
|
addRootFlags(cmd.Flags(), opts)
|
||||||
|
|
||||||
|
rawArgv := os.Args[1:]
|
||||||
|
if err := cmd.ParseFlags(rawArgv); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
args := cmd.Flags().Args()
|
||||||
|
|
||||||
|
v, err := config.NewViper(opts.ConfigFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildSingleConfig(cmd, args, rawArgv, opts, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts *cliOptions, v *viper.Viper) (*Config, error) {
|
||||||
|
backendName := defaultBackendName
|
||||||
|
model := ""
|
||||||
|
reasoningEffort := ""
|
||||||
|
agentName := ""
|
||||||
|
promptFile := ""
|
||||||
|
promptFileExplicit := false
|
||||||
|
yolo := false
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("agent") {
|
||||||
|
agentName = strings.TrimSpace(opts.Agent)
|
||||||
|
if agentName == "" {
|
||||||
|
return nil, fmt.Errorf("--agent flag requires a value")
|
||||||
|
}
|
||||||
|
if err := config.ValidateAgentName(agentName); err != nil {
|
||||||
|
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
agentName = strings.TrimSpace(v.GetString("agent"))
|
||||||
|
if agentName != "" {
|
||||||
|
if err := config.ValidateAgentName(agentName); err != nil {
|
||||||
|
return nil, fmt.Errorf("--agent flag invalid value: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string
|
||||||
|
var resolvedAllowedTools, resolvedDisallowedTools []string
|
||||||
|
if agentName != "" {
|
||||||
|
var resolvedYolo bool
|
||||||
|
var err error
|
||||||
|
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo, resolvedAllowedTools, resolvedDisallowedTools, err = config.ResolveAgentConfig(agentName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to resolve agent %q: %w", agentName, err)
|
||||||
|
}
|
||||||
|
yolo = resolvedYolo
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("prompt-file") {
|
||||||
|
promptFile = strings.TrimSpace(opts.PromptFile)
|
||||||
|
if promptFile == "" {
|
||||||
|
return nil, fmt.Errorf("--prompt-file flag requires a value")
|
||||||
|
}
|
||||||
|
promptFileExplicit = true
|
||||||
|
} else if val := strings.TrimSpace(v.GetString("prompt-file")); val != "" {
|
||||||
|
promptFile = val
|
||||||
|
promptFileExplicit = true
|
||||||
|
} else {
|
||||||
|
promptFile = resolvedPromptFile
|
||||||
|
}
|
||||||
|
|
||||||
|
agentFlagChanged := cmd.Flags().Changed("agent")
|
||||||
|
backendFlagChanged := cmd.Flags().Changed("backend")
|
||||||
|
if backendFlagChanged {
|
||||||
|
backendName = strings.TrimSpace(opts.Backend)
|
||||||
|
if backendName == "" {
|
||||||
|
return nil, fmt.Errorf("--backend flag requires a value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case agentFlagChanged && backendFlagChanged && lastFlagIndex(rawArgv, "agent") > lastFlagIndex(rawArgv, "backend"):
|
||||||
|
backendName = resolvedBackend
|
||||||
|
case !backendFlagChanged && agentName != "":
|
||||||
|
backendName = resolvedBackend
|
||||||
|
case !backendFlagChanged:
|
||||||
|
if val := strings.TrimSpace(v.GetString("backend")); val != "" {
|
||||||
|
backendName = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modelFlagChanged := cmd.Flags().Changed("model")
|
||||||
|
if modelFlagChanged {
|
||||||
|
model = strings.TrimSpace(opts.Model)
|
||||||
|
if model == "" {
|
||||||
|
return nil, fmt.Errorf("--model flag requires a value")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case agentFlagChanged && modelFlagChanged && lastFlagIndex(rawArgv, "agent") > lastFlagIndex(rawArgv, "model"):
|
||||||
|
model = strings.TrimSpace(resolvedModel)
|
||||||
|
case !modelFlagChanged && agentName != "":
|
||||||
|
model = strings.TrimSpace(resolvedModel)
|
||||||
|
case !modelFlagChanged:
|
||||||
|
model = strings.TrimSpace(v.GetString("model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("reasoning-effort") {
|
||||||
|
reasoningEffort = strings.TrimSpace(opts.ReasoningEffort)
|
||||||
|
if reasoningEffort == "" {
|
||||||
|
return nil, fmt.Errorf("--reasoning-effort flag requires a value")
|
||||||
|
}
|
||||||
|
} else if val := strings.TrimSpace(v.GetString("reasoning-effort")); val != "" {
|
||||||
|
reasoningEffort = val
|
||||||
|
} else if agentName != "" {
|
||||||
|
reasoningEffort = strings.TrimSpace(resolvedReasoning)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
|
||||||
|
skipPermissions := false
|
||||||
|
if skipChanged {
|
||||||
|
skipPermissions = opts.SkipPermissions
|
||||||
|
} else {
|
||||||
|
skipPermissions = v.GetBool("skip-permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil, fmt.Errorf("task required")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
WorkDir: defaultWorkdir,
|
||||||
|
Backend: backendName,
|
||||||
|
Agent: agentName,
|
||||||
|
PromptFile: promptFile,
|
||||||
|
PromptFileExplicit: promptFileExplicit,
|
||||||
|
SkipPermissions: skipPermissions,
|
||||||
|
Yolo: yolo,
|
||||||
|
Model: model,
|
||||||
|
ReasoningEffort: reasoningEffort,
|
||||||
|
MaxParallelWorkers: config.ResolveMaxParallelWorkers(),
|
||||||
|
AllowedTools: resolvedAllowedTools,
|
||||||
|
DisallowedTools: resolvedDisallowedTools,
|
||||||
|
Worktree: opts.Worktree,
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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 {
|
||||||
|
if args[1] == "-" {
|
||||||
|
return nil, fmt.Errorf("invalid workdir: '-' is not a valid directory path")
|
||||||
|
}
|
||||||
|
cfg.WorkDir = args[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func lastFlagIndex(argv []string, name string) int {
|
||||||
|
if len(argv) == 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
name = strings.TrimSpace(name)
|
||||||
|
if name == "" {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
needle := "--" + name
|
||||||
|
prefix := needle + "="
|
||||||
|
last := -1
|
||||||
|
for i, arg := range argv {
|
||||||
|
if arg == needle || strings.HasPrefix(arg, prefix) {
|
||||||
|
last = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
func runParallelMode(cmd *cobra.Command, args []string, opts *cliOptions, v *viper.Viper, name string) int {
|
||||||
|
if len(args) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; no positional arguments 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if cmd.Flags().Changed("agent") || cmd.Flags().Changed("prompt-file") || cmd.Flags().Changed("reasoning-effort") {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend, --model, --full-output and --skip-permissions are allowed.")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
backendName := defaultBackendName
|
||||||
|
if cmd.Flags().Changed("backend") {
|
||||||
|
backendName = strings.TrimSpace(opts.Backend)
|
||||||
|
if backendName == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else if val := strings.TrimSpace(v.GetString("backend")); val != "" {
|
||||||
|
backendName = val
|
||||||
|
}
|
||||||
|
|
||||||
|
model := ""
|
||||||
|
if cmd.Flags().Changed("model") {
|
||||||
|
model = strings.TrimSpace(opts.Model)
|
||||||
|
if model == "" {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: --model flag requires a value")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
model = strings.TrimSpace(v.GetString("model"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fullOutput := opts.FullOutput
|
||||||
|
if !cmd.Flags().Changed("full-output") && v.IsSet("full-output") {
|
||||||
|
fullOutput = v.GetBool("full-output")
|
||||||
|
}
|
||||||
|
|
||||||
|
skipChanged := cmd.Flags().Changed("skip-permissions") || cmd.Flags().Changed("dangerously-skip-permissions")
|
||||||
|
skipPermissions := false
|
||||||
|
if skipChanged {
|
||||||
|
skipPermissions = opts.SkipPermissions
|
||||||
|
} else {
|
||||||
|
skipPermissions = v.GetBool("skip-permissions")
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
for i := range results {
|
||||||
|
results[i].CoverageTarget = defaultCoverageTarget
|
||||||
|
if results[i].Message == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := strings.Split(results[i].Message, "\n")
|
||||||
|
results[i].Coverage = extractCoverageFromLines(lines)
|
||||||
|
results[i].CoverageNum = extractCoverageNum(results[i].Coverage)
|
||||||
|
results[i].FilesChanged = extractFilesChangedFromLines(lines)
|
||||||
|
results[i].TestsPassed, results[i].TestsFailed = extractTestResultsFromLines(lines)
|
||||||
|
results[i].KeyOutput = extractKeyOutputFromLines(lines, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(generateFinalOutputWithMode(results, !fullOutput))
|
||||||
|
|
||||||
|
exitCode := 0
|
||||||
|
for _, res := range results {
|
||||||
|
if res.ExitCode != 0 {
|
||||||
|
exitCode = res.ExitCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return exitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func runSingleMode(cfg *Config, name string) int {
|
||||||
|
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()
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
logger := activeLogger()
|
||||||
|
if logger == nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "ERROR: logger is not initialized")
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
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 cfg.Mode == "new" && strings.TrimSpace(taskText) == "integration-log-check" {
|
||||||
|
logInfo("Integration log check: skipping backend execution")
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
Backend: cfg.Backend,
|
||||||
|
Model: cfg.Model,
|
||||||
|
ReasoningEffort: cfg.ReasoningEffort,
|
||||||
|
Agent: cfg.Agent,
|
||||||
|
SkipPermissions: cfg.SkipPermissions,
|
||||||
|
Worktree: cfg.Worktree,
|
||||||
|
AllowedTools: cfg.AllowedTools,
|
||||||
|
DisallowedTools: cfg.DisallowedTools,
|
||||||
|
UseStdin: useStdin,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := runTaskFn(taskSpec, false, cfg.Timeout)
|
||||||
|
|
||||||
|
if result.ExitCode != 0 {
|
||||||
|
return result.ExitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate that we got a meaningful output message
|
||||||
|
if strings.TrimSpace(result.Message) == "" {
|
||||||
|
logError(fmt.Sprintf("no output message: backend=%s returned empty result.Message with exit_code=0", cfg.Backend))
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(result.Message)
|
||||||
|
if result.SessionID != "" {
|
||||||
|
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
445
codeagent-wrapper/internal/app/concurrent_stress_test.go
Normal file
445
codeagent-wrapper/internal/app/concurrent_stress_test.go
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
)
|
||||||
|
|
||||||
|
func stripTimestampPrefix(line string) string {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "{") {
|
||||||
|
var evt struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(line), &evt); err == nil && evt.Message != "" {
|
||||||
|
return evt.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
7
codeagent-wrapper/internal/app/config_alias.go
Normal file
7
codeagent-wrapper/internal/app/config_alias.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import config "codeagent-wrapper/internal/config"
|
||||||
|
|
||||||
|
// Keep the existing Config name throughout the codebase, but source the
|
||||||
|
// implementation from internal/config.
|
||||||
|
type Config = config.Config
|
||||||
54
codeagent-wrapper/internal/app/executor_alias.go
Normal file
54
codeagent-wrapper/internal/app/executor_alias.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
backend "codeagent-wrapper/internal/backend"
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
executor "codeagent-wrapper/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultRunCodexTaskFn is the default implementation of runCodexTaskFn (exposed for test reset).
|
||||||
|
func defaultRunCodexTaskFn(task TaskSpec, timeout int) TaskResult {
|
||||||
|
return executor.DefaultRunCodexTaskFn(task, timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
var runCodexTaskFn = defaultRunCodexTaskFn
|
||||||
|
|
||||||
|
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
|
||||||
|
return executor.TopologicalSort(tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeConcurrent(layers [][]TaskSpec, timeout int) []TaskResult {
|
||||||
|
maxWorkers := config.ResolveMaxParallelWorkers()
|
||||||
|
return executeConcurrentWithContext(context.Background(), layers, timeout, maxWorkers)
|
||||||
|
}
|
||||||
|
|
||||||
|
func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec, timeout int, maxWorkers int) []TaskResult {
|
||||||
|
return executor.ExecuteConcurrentWithContext(parentCtx, layers, timeout, maxWorkers, runCodexTaskFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFinalOutput(results []TaskResult) string {
|
||||||
|
return executor.GenerateFinalOutput(results)
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateFinalOutputWithMode(results []TaskResult, summaryOnly bool) string {
|
||||||
|
return executor.GenerateFinalOutputWithMode(results, summaryOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildCodexArgs(cfg *Config, targetArg string) []string {
|
||||||
|
return backend.BuildCodexArgs(cfg, targetArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCodexTask(taskSpec TaskSpec, silent bool, timeoutSec int) TaskResult {
|
||||||
|
return runCodexTaskWithContext(context.Background(), taskSpec, nil, nil, false, silent, timeoutSec)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCodexProcess(parentCtx context.Context, codexArgs []string, taskText string, useStdin bool, timeoutSec int) (message, threadID string, exitCode int) {
|
||||||
|
res := runCodexTaskWithContext(parentCtx, TaskSpec{Task: taskText, WorkDir: defaultWorkdir, Mode: "new", UseStdin: useStdin}, nil, codexArgs, true, false, timeoutSec)
|
||||||
|
return res.Message, res.SessionID, res.ExitCode
|
||||||
|
}
|
||||||
|
|
||||||
|
func runCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backend Backend, customArgs []string, useCustomArgs bool, silent bool, timeoutSec int) TaskResult {
|
||||||
|
return executor.RunCodexTaskWithContext(parentCtx, taskSpec, backend, codexCommand, buildCodexArgsFn, customArgs, useCustomArgs, silent, timeoutSec)
|
||||||
|
}
|
||||||
1132
codeagent-wrapper/internal/app/executor_concurrent_test.go
Normal file
1132
codeagent-wrapper/internal/app/executor_concurrent_test.go
Normal file
File diff suppressed because it is too large
Load Diff
26
codeagent-wrapper/internal/app/logger.go
Normal file
26
codeagent-wrapper/internal/app/logger.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import ilogger "codeagent-wrapper/internal/logger"
|
||||||
|
|
||||||
|
type Logger = ilogger.Logger
|
||||||
|
type CleanupStats = ilogger.CleanupStats
|
||||||
|
|
||||||
|
func NewLogger() (*Logger, error) { return ilogger.NewLogger() }
|
||||||
|
|
||||||
|
func NewLoggerWithSuffix(suffix string) (*Logger, error) { return ilogger.NewLoggerWithSuffix(suffix) }
|
||||||
|
|
||||||
|
func setLogger(l *Logger) { ilogger.SetLogger(l) }
|
||||||
|
|
||||||
|
func closeLogger() error { return ilogger.CloseLogger() }
|
||||||
|
|
||||||
|
func activeLogger() *Logger { return ilogger.ActiveLogger() }
|
||||||
|
|
||||||
|
func logInfo(msg string) { ilogger.LogInfo(msg) }
|
||||||
|
|
||||||
|
func logWarn(msg string) { ilogger.LogWarn(msg) }
|
||||||
|
|
||||||
|
func logError(msg string) { ilogger.LogError(msg) }
|
||||||
|
|
||||||
|
func cleanupOldLogs() (CleanupStats, error) { return ilogger.CleanupOldLogs() }
|
||||||
|
|
||||||
|
func sanitizeLogSuffix(raw string) string { return ilogger.SanitizeLogSuffix(raw) }
|
||||||
950
codeagent-wrapper/internal/app/main_integration_test.go
Normal file
950
codeagent-wrapper/internal/app/main_integration_test.go
Normal file
@@ -0,0 +1,950 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"codeagent-wrapper/internal/logger"
|
||||||
|
"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
|
||||||
|
if _, err := io.Copy(&buf, r); err != nil {
|
||||||
|
t.Fatalf("io.Copy() error = %v", err)
|
||||||
|
}
|
||||||
|
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") {
|
||||||
|
if _, err := fmt.Sscanf(p, "%d tasks", &payload.Summary.Total); err != nil {
|
||||||
|
t.Fatalf("failed to parse total tasks from %q: %v", p, err)
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(p, "passed") {
|
||||||
|
if _, err := fmt.Sscanf(p, "%d passed", &payload.Summary.Success); err != nil {
|
||||||
|
t.Fatalf("failed to parse passed tasks from %q: %v", p, err)
|
||||||
|
}
|
||||||
|
} else if strings.HasSuffix(p, "failed") {
|
||||||
|
if _, err := fmt.Sscanf(p, "%d failed", &payload.Summary.Failed); err != nil {
|
||||||
|
t.Fatalf("failed to parse failed tasks from %q: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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:") {
|
||||||
|
if _, err := fmt.Sscanf(p, "Total: %d", &payload.Summary.Total); err != nil {
|
||||||
|
t.Fatalf("failed to parse total tasks from %q: %v", p, err)
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(p, "Success:") {
|
||||||
|
if _, err := fmt.Sscanf(p, "Success: %d", &payload.Summary.Success); err != nil {
|
||||||
|
t.Fatalf("failed to parse passed tasks from %q: %v", p, err)
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(p, "Failed:") {
|
||||||
|
if _, err := fmt.Sscanf(p, "Failed: %d", &payload.Summary.Failed); err != nil {
|
||||||
|
t.Fatalf("failed to parse failed tasks from %q: %v", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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, "### ")
|
||||||
|
parseMarker := func(marker string, exitCode int) bool {
|
||||||
|
needle := " " + marker
|
||||||
|
if !strings.Contains(taskLine, needle) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(taskLine, needle)
|
||||||
|
currentTask.TaskID = strings.TrimSpace(parts[0])
|
||||||
|
currentTask.ExitCode = exitCode
|
||||||
|
if exitCode == 0 && len(parts) > 1 {
|
||||||
|
coveragePart := strings.TrimSpace(parts[1])
|
||||||
|
if strings.HasSuffix(coveragePart, "%") {
|
||||||
|
currentTask.Coverage = coveragePart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case parseMarker("✓", 0), parseMarker("PASS", 0):
|
||||||
|
// ok
|
||||||
|
case parseMarker("⚠️", 0), parseMarker("WARN", 0):
|
||||||
|
// warning
|
||||||
|
case parseMarker("✗", 1), parseMarker("FAIL", 1):
|
||||||
|
// fail
|
||||||
|
default:
|
||||||
|
currentTask.TaskID = taskLine
|
||||||
|
}
|
||||||
|
} else if currentTask != nil && inTaskResults {
|
||||||
|
// Parse task details
|
||||||
|
if strings.HasPrefix(line, "Exit code:") {
|
||||||
|
if _, err := fmt.Sscanf(line, "Exit code: %d", ¤tTask.ExitCode); err != nil {
|
||||||
|
t.Fatalf("failed to parse exit code from %q: %v", line, err)
|
||||||
|
}
|
||||||
|
} 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") {
|
||||||
|
if _, err := fmt.Sscanf(line, "Status: FAILED (exit code %d)", ¤tTask.ExitCode); err != nil {
|
||||||
|
t.Fatalf("failed to parse exit code from %q: %v", line, err)
|
||||||
|
}
|
||||||
|
} 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 setTempDirEnv(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
resolved := dir
|
||||||
|
if eval, err := filepath.EvalSymlinks(dir); err == nil {
|
||||||
|
resolved = eval
|
||||||
|
}
|
||||||
|
t.Setenv("TMPDIR", resolved)
|
||||||
|
t.Setenv("TEMP", resolved)
|
||||||
|
t.Setenv("TMP", resolved)
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
func createTempLog(t *testing.T, dir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte("test"), 0o644); err != nil {
|
||||||
|
t.Fatalf("failed to create temp log %s: %v", path, err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubProcessRunning(t *testing.T, fn func(int) bool) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(logger.SetProcessRunningCheck(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
func stubProcessStartTime(t *testing.T, fn func(int) time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
t.Cleanup(logger.SetProcessStartTimeFn(fn))
|
||||||
|
}
|
||||||
|
|
||||||
|
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{"codeagent-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{"codeagent-wrapper", "--parallel"}
|
||||||
|
|
||||||
|
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codeagent-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("codeagent-wrapper-%d-a.log", os.Getpid()))): {},
|
||||||
|
fmt.Sprintf("Task b: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d-b.log", os.Getpid()))): {},
|
||||||
|
fmt.Sprintf("Task d: Log: %s", filepath.Join(tempDir, fmt.Sprintf("codeagent-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{"codeagent-wrapper", "integration-log-check"}
|
||||||
|
stdinReader = strings.NewReader("")
|
||||||
|
isTerminalFn = func() bool { return true }
|
||||||
|
codexCommand = createFakeCodexScript(t, "integration-session", "done")
|
||||||
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||||
|
|
||||||
|
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("codeagent-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()
|
||||||
|
_ = executeConcurrentWithContext(nil, layers, 5, 1)
|
||||||
|
serialElapsed := time.Since(serialStart)
|
||||||
|
|
||||||
|
concurrentStart := time.Now()
|
||||||
|
_ = executeConcurrentWithContext(nil, layers, 5, 0)
|
||||||
|
concurrentElapsed := time.Since(concurrentStart)
|
||||||
|
|
||||||
|
ratio := float64(concurrentElapsed) / float64(serialElapsed)
|
||||||
|
t.Logf("speedup ratio (concurrent/serial)=%.3f", ratio)
|
||||||
|
if concurrentElapsed >= serialElapsed/2 {
|
||||||
|
t.Fatalf("expected concurrent time <50%% of serial, serial=%v concurrent=%v", serialElapsed, concurrentElapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
|
||||||
|
defer resetTestHooks()
|
||||||
|
|
||||||
|
tempDir := setTempDirEnv(t, t.TempDir())
|
||||||
|
|
||||||
|
orphanA := createTempLog(t, tempDir, "codeagent-wrapper-5001.log")
|
||||||
|
orphanB := createTempLog(t, tempDir, "codeagent-wrapper-5002-extra.log")
|
||||||
|
orphanC := createTempLog(t, tempDir, "codeagent-wrapper-5003-suffix.log")
|
||||||
|
runningPID := 81234
|
||||||
|
runningLog := createTempLog(t, tempDir, fmt.Sprintf("codeagent-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{"codeagent-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("codeagent-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, "codeagent-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())
|
||||||
|
|
||||||
|
basePID := os.Getpid()
|
||||||
|
stalePID1 := basePID + 10000
|
||||||
|
stalePID2 := basePID + 11000
|
||||||
|
keeperPID := basePID + 12000
|
||||||
|
|
||||||
|
staleA := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", stalePID1))
|
||||||
|
staleB := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d-extra.log", stalePID2))
|
||||||
|
keeper := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", keeperPID))
|
||||||
|
|
||||||
|
stubProcessRunning(t, func(pid int) bool {
|
||||||
|
return pid == keeperPID || pid == basePID
|
||||||
|
})
|
||||||
|
stubProcessStartTime(t, func(pid int) time.Time {
|
||||||
|
if pid == keeperPID || pid == basePID {
|
||||||
|
return time.Now().Add(-1 * time.Hour)
|
||||||
|
}
|
||||||
|
return time.Time{}
|
||||||
|
})
|
||||||
|
|
||||||
|
os.Args = []string{"codeagent-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, fmt.Sprintf("codeagent-wrapper-%d.log", stalePID1)) || !strings.Contains(output, fmt.Sprintf("codeagent-wrapper-%d-extra.log", stalePID2)) {
|
||||||
|
t.Fatalf("missing deleted file names in output: %q", output)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output, fmt.Sprintf("codeagent-wrapper-%d.log", keeperPID)) {
|
||||||
|
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("codeagent-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{"codeagent-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("codeagent-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{"codeagent-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)
|
||||||
|
}
|
||||||
|
}
|
||||||
4859
codeagent-wrapper/internal/app/main_test.go
Normal file
4859
codeagent-wrapper/internal/app/main_test.go
Normal file
File diff suppressed because it is too large
Load Diff
46
codeagent-wrapper/internal/app/os_paths_test.go
Normal file
46
codeagent-wrapper/internal/app/os_paths_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseArgs_Workdir_OSPaths(t *testing.T) {
|
||||||
|
oldArgv := os.Args
|
||||||
|
t.Cleanup(func() { os.Args = oldArgv })
|
||||||
|
|
||||||
|
workdirs := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
}{
|
||||||
|
{name: "windows drive forward slashes", path: "D:/repo/path"},
|
||||||
|
{name: "windows drive backslashes", path: `C:\repo\path`},
|
||||||
|
{name: "windows UNC", path: `\\server\share\repo`},
|
||||||
|
{name: "unix absolute", path: "/home/user/repo"},
|
||||||
|
{name: "relative", path: "./relative/repo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, wd := range workdirs {
|
||||||
|
t.Run("new mode: "+wd.name, func(t *testing.T) {
|
||||||
|
os.Args = []string{"codeagent-wrapper", "task", wd.path}
|
||||||
|
cfg, err := parseArgs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseArgs() error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Mode != "new" || cfg.Task != "task" || cfg.WorkDir != wd.path {
|
||||||
|
t.Fatalf("cfg mismatch: got mode=%q task=%q workdir=%q, want mode=%q task=%q workdir=%q", cfg.Mode, cfg.Task, cfg.WorkDir, "new", "task", wd.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("resume mode: "+wd.name, func(t *testing.T) {
|
||||||
|
os.Args = []string{"codeagent-wrapper", "resume", "sid-1", "task", wd.path}
|
||||||
|
cfg, err := parseArgs()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseArgs() error: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Mode != "resume" || cfg.SessionID != "sid-1" || cfg.Task != "task" || cfg.WorkDir != wd.path {
|
||||||
|
t.Fatalf("cfg mismatch: got mode=%q sid=%q task=%q workdir=%q, want mode=%q sid=%q task=%q workdir=%q", cfg.Mode, cfg.SessionID, cfg.Task, cfg.WorkDir, "resume", "sid-1", "task", wd.path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
9
codeagent-wrapper/internal/app/parallel_config.go
Normal file
9
codeagent-wrapper/internal/app/parallel_config.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
executor "codeagent-wrapper/internal/executor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||||
|
return executor.ParseParallelConfig(data)
|
||||||
|
}
|
||||||
34
codeagent-wrapper/internal/app/parser.go
Normal file
34
codeagent-wrapper/internal/app/parser.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
parser "codeagent-wrapper/internal/parser"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func(), onComplete func()) (message, threadID string) {
|
||||||
|
return parser.ParseJSONStreamInternal(r, warnFn, infoFn, onMessage, onComplete)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasKey(m map[string]json.RawMessage, key string) bool { return parser.HasKey(m, key) }
|
||||||
|
|
||||||
|
func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
|
||||||
|
return parser.DiscardInvalidJSON(decoder, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeText(text interface{}) string { return parser.NormalizeText(text) }
|
||||||
119
codeagent-wrapper/internal/app/stdin_mode_test.go
Normal file
119
codeagent-wrapper/internal/app/stdin_mode_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRunSingleMode_UseStdin_TargetArgAndTaskText(t *testing.T) {
|
||||||
|
defer resetTestHooks()
|
||||||
|
|
||||||
|
setTempDirEnv(t, t.TempDir())
|
||||||
|
logger, err := NewLogger()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewLogger(): %v", err)
|
||||||
|
}
|
||||||
|
setLogger(logger)
|
||||||
|
t.Cleanup(func() { _ = closeLogger() })
|
||||||
|
|
||||||
|
type testCase struct {
|
||||||
|
name string
|
||||||
|
cfgTask string
|
||||||
|
explicit bool
|
||||||
|
stdinData string
|
||||||
|
isTerminal bool
|
||||||
|
|
||||||
|
wantUseStdin bool
|
||||||
|
wantTarget string
|
||||||
|
wantTaskText string
|
||||||
|
}
|
||||||
|
|
||||||
|
longTask := strings.Repeat("a", 801)
|
||||||
|
|
||||||
|
tests := []testCase{
|
||||||
|
{
|
||||||
|
name: "piped input forces stdin mode",
|
||||||
|
cfgTask: "cli-task",
|
||||||
|
stdinData: "piped task text",
|
||||||
|
isTerminal: false,
|
||||||
|
wantUseStdin: true,
|
||||||
|
wantTarget: "-",
|
||||||
|
wantTaskText: "piped task text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "explicit dash forces stdin mode",
|
||||||
|
cfgTask: "-",
|
||||||
|
explicit: true,
|
||||||
|
stdinData: "explicit task text",
|
||||||
|
isTerminal: true,
|
||||||
|
wantUseStdin: true,
|
||||||
|
wantTarget: "-",
|
||||||
|
wantTaskText: "explicit task text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "special char backslash forces stdin mode",
|
||||||
|
cfgTask: `C:\repo\file.go`,
|
||||||
|
isTerminal: true,
|
||||||
|
wantUseStdin: true,
|
||||||
|
wantTarget: "-",
|
||||||
|
wantTaskText: `C:\repo\file.go`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "length>800 forces stdin mode",
|
||||||
|
cfgTask: longTask,
|
||||||
|
isTerminal: true,
|
||||||
|
wantUseStdin: true,
|
||||||
|
wantTarget: "-",
|
||||||
|
wantTaskText: longTask,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple task uses argv target",
|
||||||
|
cfgTask: "analyze code",
|
||||||
|
isTerminal: true,
|
||||||
|
wantUseStdin: false,
|
||||||
|
wantTarget: "analyze code",
|
||||||
|
wantTaskText: "analyze code",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var gotTarget string
|
||||||
|
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
||||||
|
gotTarget = targetArg
|
||||||
|
return []string{targetArg}
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotTask TaskSpec
|
||||||
|
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
||||||
|
gotTask = task
|
||||||
|
return TaskResult{ExitCode: 0, Message: "ok"}
|
||||||
|
}
|
||||||
|
|
||||||
|
stdinReader = strings.NewReader(tt.stdinData)
|
||||||
|
isTerminalFn = func() bool { return tt.isTerminal }
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
Mode: "new",
|
||||||
|
Task: tt.cfgTask,
|
||||||
|
WorkDir: defaultWorkdir,
|
||||||
|
Backend: defaultBackendName,
|
||||||
|
ExplicitStdin: tt.explicit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := runSingleMode(cfg, "codeagent-wrapper"); code != 0 {
|
||||||
|
t.Fatalf("runSingleMode() = %d, want 0", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if gotTarget != tt.wantTarget {
|
||||||
|
t.Fatalf("targetArg = %q, want %q", gotTarget, tt.wantTarget)
|
||||||
|
}
|
||||||
|
if gotTask.UseStdin != tt.wantUseStdin {
|
||||||
|
t.Fatalf("taskSpec.UseStdin = %v, want %v", gotTask.UseStdin, tt.wantUseStdin)
|
||||||
|
}
|
||||||
|
if gotTask.Task != tt.wantTaskText {
|
||||||
|
t.Fatalf("taskSpec.Task = %q, want %q", gotTask.Task, tt.wantTaskText)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
8
codeagent-wrapper/internal/app/task_types.go
Normal file
8
codeagent-wrapper/internal/app/task_types.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import executor "codeagent-wrapper/internal/executor"
|
||||||
|
|
||||||
|
// Type aliases to keep existing names in the wrapper package.
|
||||||
|
type ParallelConfig = executor.ParallelConfig
|
||||||
|
type TaskSpec = executor.TaskSpec
|
||||||
|
type TaskResult = executor.TaskResult
|
||||||
30
codeagent-wrapper/internal/app/terminal_test.go
Normal file
30
codeagent-wrapper/internal/app/terminal_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDefaultIsTerminalCoverage(t *testing.T) {
|
||||||
|
oldStdin := os.Stdin
|
||||||
|
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(t.TempDir(), "stdin-*")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.CreateTemp() error = %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(f.Name())
|
||||||
|
|
||||||
|
os.Stdin = f
|
||||||
|
if got := defaultIsTerminal(); got {
|
||||||
|
t.Fatalf("defaultIsTerminal() = %v, want false for regular file", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
t.Fatalf("Close() error = %v", err)
|
||||||
|
}
|
||||||
|
os.Stdin = f
|
||||||
|
if got := defaultIsTerminal(); !got {
|
||||||
|
t.Fatalf("defaultIsTerminal() = %v, want true when Stat fails", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
134
codeagent-wrapper/internal/app/tmpdir.go
Normal file
134
codeagent-wrapper/internal/app/tmpdir.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const tmpDirEnvOverrideKey = "CODEAGENT_TMPDIR"
|
||||||
|
|
||||||
|
var tmpDirExecutableCheckFn = canExecuteInDir
|
||||||
|
|
||||||
|
func ensureExecutableTempDir() {
|
||||||
|
// Windows doesn't execute scripts via shebang, and os.TempDir semantics differ.
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if override := strings.TrimSpace(os.Getenv(tmpDirEnvOverrideKey)); override != "" {
|
||||||
|
if resolved, err := resolvePathWithTilde(override); err == nil {
|
||||||
|
if err := os.MkdirAll(resolved, 0o700); err == nil {
|
||||||
|
if ok, _ := tmpDirExecutableCheckFn(resolved); ok {
|
||||||
|
setTempEnv(resolved)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Invalid override should not block execution; fall back to default behavior.
|
||||||
|
}
|
||||||
|
|
||||||
|
current := currentTempDirFromEnv()
|
||||||
|
if current == "" {
|
||||||
|
current = "/tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, _ := tmpDirExecutableCheckFn(current)
|
||||||
|
if ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fallback := defaultFallbackTempDir()
|
||||||
|
if fallback == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(fallback, 0o700); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ok, _ := tmpDirExecutableCheckFn(fallback); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setTempEnv(fallback)
|
||||||
|
fmt.Fprintf(os.Stderr, "INFO: temp dir is not executable; set TMPDIR=%s\n", fallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setTempEnv(dir string) {
|
||||||
|
_ = os.Setenv("TMPDIR", dir)
|
||||||
|
_ = os.Setenv("TMP", dir)
|
||||||
|
_ = os.Setenv("TEMP", dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultFallbackTempDir() string {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(home) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Clean(filepath.Join(home, ".codeagent", "tmp"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentTempDirFromEnv() string {
|
||||||
|
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||||
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolvePathWithTilde(p string) (string, error) {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return "", errors.New("empty path")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~\\") {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(home) == "" {
|
||||||
|
if err == nil {
|
||||||
|
err = errors.New("empty home directory")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("resolve ~: %w", err)
|
||||||
|
}
|
||||||
|
if p == "~" {
|
||||||
|
return home, nil
|
||||||
|
}
|
||||||
|
return filepath.Clean(home + p[1:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Clean(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func canExecuteInDir(dir string) (bool, error) {
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
return false, errors.New("empty dir")
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.CreateTemp(dir, "codeagent-tmp-exec-*")
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
path := f.Name()
|
||||||
|
defer func() { _ = os.Remove(path) }()
|
||||||
|
|
||||||
|
if _, err := f.WriteString("#!/bin/sh\nexit 0\n"); err != nil {
|
||||||
|
_ = f.Close()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := f.Close(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(path, 0o700); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := exec.Command(path).Run(); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
103
codeagent-wrapper/internal/app/tmpdir_test.go
Normal file
103
codeagent-wrapper/internal/app/tmpdir_test.go
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEnsureExecutableTempDir_Override(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("ensureExecutableTempDir is no-op on Windows")
|
||||||
|
}
|
||||||
|
restore := captureTempEnv()
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
t.Setenv("HOME", t.TempDir())
|
||||||
|
t.Setenv("USERPROFILE", os.Getenv("HOME"))
|
||||||
|
|
||||||
|
orig := tmpDirExecutableCheckFn
|
||||||
|
tmpDirExecutableCheckFn = func(string) (bool, error) { return true, nil }
|
||||||
|
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
|
||||||
|
|
||||||
|
override := filepath.Join(t.TempDir(), "mytmp")
|
||||||
|
t.Setenv(tmpDirEnvOverrideKey, override)
|
||||||
|
|
||||||
|
ensureExecutableTempDir()
|
||||||
|
|
||||||
|
if got := os.Getenv("TMPDIR"); got != override {
|
||||||
|
t.Fatalf("TMPDIR=%q, want %q", got, override)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("TMP"); got != override {
|
||||||
|
t.Fatalf("TMP=%q, want %q", got, override)
|
||||||
|
}
|
||||||
|
if got := os.Getenv("TEMP"); got != override {
|
||||||
|
t.Fatalf("TEMP=%q, want %q", got, override)
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(override); err != nil || !st.IsDir() {
|
||||||
|
t.Fatalf("override dir not created: stat=%v err=%v", st, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureExecutableTempDir_FallbackWhenCurrentNotExecutable(t *testing.T) {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
t.Skip("ensureExecutableTempDir is no-op on Windows")
|
||||||
|
}
|
||||||
|
restore := captureTempEnv()
|
||||||
|
t.Cleanup(restore)
|
||||||
|
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
|
||||||
|
cur := filepath.Join(t.TempDir(), "cur-tmp")
|
||||||
|
if err := os.MkdirAll(cur, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv("TMPDIR", cur)
|
||||||
|
|
||||||
|
fallback := filepath.Join(home, ".codeagent", "tmp")
|
||||||
|
|
||||||
|
orig := tmpDirExecutableCheckFn
|
||||||
|
tmpDirExecutableCheckFn = func(dir string) (bool, error) {
|
||||||
|
if filepath.Clean(dir) == filepath.Clean(cur) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if filepath.Clean(dir) == filepath.Clean(fallback) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
|
||||||
|
|
||||||
|
ensureExecutableTempDir()
|
||||||
|
|
||||||
|
if got := os.Getenv("TMPDIR"); filepath.Clean(got) != filepath.Clean(fallback) {
|
||||||
|
t.Fatalf("TMPDIR=%q, want %q", got, fallback)
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(fallback); err != nil || !st.IsDir() {
|
||||||
|
t.Fatalf("fallback dir not created: stat=%v err=%v", st, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureTempEnv() func() {
|
||||||
|
type entry struct {
|
||||||
|
set bool
|
||||||
|
val string
|
||||||
|
}
|
||||||
|
snapshot := make(map[string]entry, 3)
|
||||||
|
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||||
|
v, ok := os.LookupEnv(k)
|
||||||
|
snapshot[k] = entry{set: ok, val: v}
|
||||||
|
}
|
||||||
|
return func() {
|
||||||
|
for k, e := range snapshot {
|
||||||
|
if !e.set {
|
||||||
|
_ = os.Unsetenv(k)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = os.Setenv(k, e.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
523
codeagent-wrapper/internal/app/utils.go
Normal file
523
codeagent-wrapper/internal/app/utils.go
Normal file
@@ -0,0 +1,523 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
utils "codeagent-wrapper/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.ContainsAny(taskText, stdinSpecialChars)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return utils.Truncate(s, maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// safeTruncate safely truncates string to maxLen, avoiding panic and UTF-8 corruption.
|
||||||
|
func safeTruncate(s string, maxLen int) string {
|
||||||
|
return utils.SafeTruncate(s, maxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeOutput removes ANSI escape sequences and control characters.
|
||||||
|
func sanitizeOutput(s string) string {
|
||||||
|
return utils.SanitizeOutput(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func min(a, b int) int {
|
||||||
|
return utils.Min(a, 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)
|
||||||
|
}
|
||||||
143
codeagent-wrapper/internal/app/utils_test.go
Normal file
143
codeagent-wrapper/internal/app/utils_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
9
codeagent-wrapper/internal/app/wrapper_name.go
Normal file
9
codeagent-wrapper/internal/app/wrapper_name.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
package wrapper
|
||||||
|
|
||||||
|
import ilogger "codeagent-wrapper/internal/logger"
|
||||||
|
|
||||||
|
const wrapperName = ilogger.WrapperName
|
||||||
|
|
||||||
|
func currentWrapperName() string { return ilogger.CurrentWrapperName() }
|
||||||
|
|
||||||
|
func primaryLogPrefix() string { return ilogger.PrimaryLogPrefix() }
|
||||||
33
codeagent-wrapper/internal/backend/backend.go
Normal file
33
codeagent-wrapper/internal/backend/backend.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import config "codeagent-wrapper/internal/config"
|
||||||
|
|
||||||
|
// 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.Config, targetArg string) []string
|
||||||
|
Command() string
|
||||||
|
Env(baseURL, apiKey string) map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
logWarnFn = func(string) {}
|
||||||
|
logErrorFn = func(string) {}
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetLogFuncs configures optional logging hooks used by some backends.
|
||||||
|
// Callers can safely pass nil to disable the hook.
|
||||||
|
func SetLogFuncs(warnFn, errorFn func(string)) {
|
||||||
|
if warnFn != nil {
|
||||||
|
logWarnFn = warnFn
|
||||||
|
} else {
|
||||||
|
logWarnFn = func(string) {}
|
||||||
|
}
|
||||||
|
if errorFn != nil {
|
||||||
|
logErrorFn = errorFn
|
||||||
|
} else {
|
||||||
|
logErrorFn = func(string) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
322
codeagent-wrapper/internal/backend/backend_test.go
Normal file
322
codeagent-wrapper/internal/backend/backend_test.go
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.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.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.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.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.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.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.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.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.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.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.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.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.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.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOpencodeBackend_BuildArgs(t *testing.T) {
|
||||||
|
backend := OpencodeBackend{}
|
||||||
|
|
||||||
|
t.Run("basic", func(t *testing.T) {
|
||||||
|
cfg := &config.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.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.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.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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stdin mode omits dash", func(t *testing.T) {
|
||||||
|
cfg := &config.Config{Mode: "new"}
|
||||||
|
got := backend.BuildArgs(cfg, "-")
|
||||||
|
want := []string{"run", "--format", "json"}
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
149
codeagent-wrapper/internal/backend/claude.go
Normal file
149
codeagent-wrapper/internal/backend/claude.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClaudeBackend struct{}
|
||||||
|
|
||||||
|
func (ClaudeBackend) Name() string { return "claude" }
|
||||||
|
func (ClaudeBackend) Command() string { return "claude" }
|
||||||
|
func (ClaudeBackend) Env(baseURL, apiKey string) map[string]string {
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if baseURL == "" && apiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
env := make(map[string]string, 2)
|
||||||
|
if baseURL != "" {
|
||||||
|
env["ANTHROPIC_BASE_URL"] = baseURL
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
// Claude Code CLI uses ANTHROPIC_API_KEY for API-key based auth.
|
||||||
|
env["ANTHROPIC_API_KEY"] = apiKey
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
func (ClaudeBackend) BuildArgs(cfg *config.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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
claudeDir := filepath.Clean(filepath.Join(home, ".claude"))
|
||||||
|
settingPath := filepath.Clean(filepath.Join(claudeDir, "settings.json"))
|
||||||
|
rel, err := filepath.Rel(claudeDir, settingPath)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
|
return MinimalClaudeSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(settingPath)
|
||||||
|
if err != nil || info.Size() > MaxClaudeSettingsBytes {
|
||||||
|
return MinimalClaudeSettings{}
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(settingPath) // #nosec G304 -- path is fixed under user home and validated to stay within claudeDir
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadMinimalEnvSettings() map[string]string {
|
||||||
|
settings := LoadMinimalClaudeSettings()
|
||||||
|
if len(settings.Env) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return settings.Env
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClaudeArgs(cfg *config.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 || config.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cfg.AllowedTools) > 0 {
|
||||||
|
args = append(args, "--allowedTools")
|
||||||
|
args = append(args, cfg.AllowedTools...)
|
||||||
|
}
|
||||||
|
if len(cfg.DisallowedTools) > 0 {
|
||||||
|
args = append(args, "--disallowedTools")
|
||||||
|
args = append(args, cfg.DisallowedTools...)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
79
codeagent-wrapper/internal/backend/codex.go
Normal file
79
codeagent-wrapper/internal/backend/codex.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type CodexBackend struct{}
|
||||||
|
|
||||||
|
func (CodexBackend) Name() string { return "codex" }
|
||||||
|
func (CodexBackend) Command() string { return "codex" }
|
||||||
|
func (CodexBackend) Env(baseURL, apiKey string) map[string]string {
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if baseURL == "" && apiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
env := make(map[string]string, 2)
|
||||||
|
if baseURL != "" {
|
||||||
|
env["OPENAI_BASE_URL"] = baseURL
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
env["OPENAI_API_KEY"] = apiKey
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
func (CodexBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||||
|
return BuildCodexArgs(cfg, targetArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func BuildCodexArgs(cfg *config.Config, targetArg string) []string {
|
||||||
|
if cfg == nil {
|
||||||
|
panic("buildCodexArgs: nil config")
|
||||||
|
}
|
||||||
|
|
||||||
|
var resumeSessionID string
|
||||||
|
isResume := cfg.Mode == "resume"
|
||||||
|
if isResume {
|
||||||
|
resumeSessionID = strings.TrimSpace(cfg.SessionID)
|
||||||
|
if resumeSessionID == "" {
|
||||||
|
logErrorFn("invalid config: resume mode requires non-empty session_id")
|
||||||
|
isResume = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"e"}
|
||||||
|
|
||||||
|
// Default to bypass sandbox unless CODEX_BYPASS_SANDBOX=false
|
||||||
|
if cfg.Yolo || config.EnvFlagDefaultTrue("CODEX_BYPASS_SANDBOX") {
|
||||||
|
logWarnFn("YOLO mode or CODEX_BYPASS_SANDBOX enabled: running without approval/sandbox protection")
|
||||||
|
args = append(args, "--dangerously-bypass-approvals-and-sandbox")
|
||||||
|
}
|
||||||
|
|
||||||
|
if model := strings.TrimSpace(cfg.Model); model != "" {
|
||||||
|
args = append(args, "--model", model)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reasoningEffort := strings.TrimSpace(cfg.ReasoningEffort); reasoningEffort != "" {
|
||||||
|
args = append(args, "-c", "model_reasoning_effort="+reasoningEffort)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, "--skip-git-repo-check")
|
||||||
|
|
||||||
|
if isResume {
|
||||||
|
return append(args,
|
||||||
|
"--json",
|
||||||
|
"resume",
|
||||||
|
resumeSessionID,
|
||||||
|
targetArg,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(args,
|
||||||
|
"-C", cfg.WorkDir,
|
||||||
|
"--json",
|
||||||
|
targetArg,
|
||||||
|
)
|
||||||
|
}
|
||||||
54
codeagent-wrapper/internal/backend/codex_paths_test.go
Normal file
54
codeagent-wrapper/internal/backend/codex_paths_test.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBuildCodexArgs_Workdir_OSPaths(t *testing.T) {
|
||||||
|
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
workdir string
|
||||||
|
}{
|
||||||
|
{name: "windows drive forward slashes", workdir: "D:/repo/path"},
|
||||||
|
{name: "windows drive backslashes", workdir: `C:\repo\path`},
|
||||||
|
{name: "windows UNC", workdir: `\\server\share\repo`},
|
||||||
|
{name: "unix absolute", workdir: "/home/user/repo"},
|
||||||
|
{name: "relative", workdir: "./relative/repo"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &config.Config{Mode: "new", WorkDir: tt.workdir}
|
||||||
|
got := BuildCodexArgs(cfg, "task")
|
||||||
|
want := []string{"e", "--skip-git-repo-check", "-C", tt.workdir, "--json", "task"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("new mode stdin target uses dash", func(t *testing.T) {
|
||||||
|
cfg := &config.Config{Mode: "new", WorkDir: `C:\repo\path`}
|
||||||
|
got := BuildCodexArgs(cfg, "-")
|
||||||
|
want := []string{"e", "--skip-git-repo-check", "-C", `C:\repo\path`, "--json", "-"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildCodexArgs_ResumeMode_OmitsWorkdir(t *testing.T) {
|
||||||
|
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
||||||
|
|
||||||
|
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", WorkDir: `C:\repo\path`}
|
||||||
|
got := BuildCodexArgs(cfg, "-")
|
||||||
|
want := []string{"e", "--skip-git-repo-check", "--json", "resume", "sid-123", "-"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
110
codeagent-wrapper/internal/backend/gemini.go
Normal file
110
codeagent-wrapper/internal/backend/gemini.go
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeminiBackend struct{}
|
||||||
|
|
||||||
|
func (GeminiBackend) Name() string { return "gemini" }
|
||||||
|
func (GeminiBackend) Command() string { return "gemini" }
|
||||||
|
func (GeminiBackend) Env(baseURL, apiKey string) map[string]string {
|
||||||
|
baseURL = strings.TrimSpace(baseURL)
|
||||||
|
apiKey = strings.TrimSpace(apiKey)
|
||||||
|
if baseURL == "" && apiKey == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
env := make(map[string]string, 2)
|
||||||
|
if baseURL != "" {
|
||||||
|
env["GOOGLE_GEMINI_BASE_URL"] = baseURL
|
||||||
|
}
|
||||||
|
if apiKey != "" {
|
||||||
|
env["GEMINI_API_KEY"] = apiKey
|
||||||
|
}
|
||||||
|
return env
|
||||||
|
}
|
||||||
|
func (GeminiBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||||
|
return buildGeminiArgs(cfg, targetArg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
envDir := filepath.Clean(filepath.Join(home, ".gemini"))
|
||||||
|
envPath := filepath.Clean(filepath.Join(envDir, ".env"))
|
||||||
|
rel, err := filepath.Rel(envDir, envPath)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(envPath) // #nosec G304 -- path is fixed under user home and validated to stay within envDir
|
||||||
|
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 buildGeminiArgs(cfg *config.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
29
codeagent-wrapper/internal/backend/opencode.go
Normal file
29
codeagent-wrapper/internal/backend/opencode.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OpencodeBackend struct{}
|
||||||
|
|
||||||
|
func (OpencodeBackend) Name() string { return "opencode" }
|
||||||
|
func (OpencodeBackend) Command() string { return "opencode" }
|
||||||
|
func (OpencodeBackend) Env(baseURL, apiKey string) map[string]string { return nil }
|
||||||
|
func (OpencodeBackend) BuildArgs(cfg *config.Config, targetArg string) []string {
|
||||||
|
args := []string{"run"}
|
||||||
|
if cfg != nil {
|
||||||
|
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")
|
||||||
|
if targetArg != "-" {
|
||||||
|
args = append(args, targetArg)
|
||||||
|
}
|
||||||
|
return args
|
||||||
|
}
|
||||||
29
codeagent-wrapper/internal/backend/registry.go
Normal file
29
codeagent-wrapper/internal/backend/registry.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package backend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var registry = map[string]Backend{
|
||||||
|
"codex": CodexBackend{},
|
||||||
|
"claude": ClaudeBackend{},
|
||||||
|
"gemini": GeminiBackend{},
|
||||||
|
"opencode": OpencodeBackend{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry exposes the available backends. Intended for internal inspection/tests.
|
||||||
|
func Registry() map[string]Backend {
|
||||||
|
return registry
|
||||||
|
}
|
||||||
|
|
||||||
|
func Select(name string) (Backend, error) {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if key == "" {
|
||||||
|
key = "codex"
|
||||||
|
}
|
||||||
|
if backend, ok := registry[key]; ok {
|
||||||
|
return backend, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("unsupported backend %q", name)
|
||||||
|
}
|
||||||
261
codeagent-wrapper/internal/config/agent.go
Normal file
261
codeagent-wrapper/internal/config/agent.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type BackendConfig struct {
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
BaseURL string `json:"base_url,omitempty"`
|
||||||
|
APIKey string `json:"api_key,omitempty"`
|
||||||
|
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||||
|
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ModelsConfig struct {
|
||||||
|
DefaultBackend string `json:"default_backend"`
|
||||||
|
DefaultModel string `json:"default_model"`
|
||||||
|
Agents map[string]AgentModelConfig `json:"agents"`
|
||||||
|
Backends map[string]BackendConfig `json:"backends,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultModelsConfig = ModelsConfig{}
|
||||||
|
|
||||||
|
const modelsConfigTildePath = "~/.codeagent/models.json"
|
||||||
|
|
||||||
|
const modelsConfigExample = `{
|
||||||
|
"default_backend": "codex",
|
||||||
|
"default_model": "gpt-4.1",
|
||||||
|
"backends": {
|
||||||
|
"codex": { "api_key": "..." },
|
||||||
|
"claude": { "api_key": "..." }
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"develop": {
|
||||||
|
"backend": "codex",
|
||||||
|
"model": "gpt-4.1",
|
||||||
|
"prompt_file": "~/.codeagent/prompts/develop.md",
|
||||||
|
"reasoning": "high",
|
||||||
|
"yolo": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
var (
|
||||||
|
modelsConfigOnce sync.Once
|
||||||
|
modelsConfigCached *ModelsConfig
|
||||||
|
modelsConfigErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func modelsConfig() (*ModelsConfig, error) {
|
||||||
|
modelsConfigOnce.Do(func() {
|
||||||
|
modelsConfigCached, modelsConfigErr = loadModelsConfig()
|
||||||
|
})
|
||||||
|
return modelsConfigCached, modelsConfigErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelsConfigPath() (string, error) {
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(home) == "" {
|
||||||
|
return "", fmt.Errorf("failed to resolve user home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configDir := filepath.Clean(filepath.Join(home, ".codeagent"))
|
||||||
|
configPath := filepath.Clean(filepath.Join(configDir, "models.json"))
|
||||||
|
rel, err := filepath.Rel(configDir, configPath)
|
||||||
|
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||||
|
return "", fmt.Errorf("refusing to read models config outside %s: %s", configDir, configPath)
|
||||||
|
}
|
||||||
|
return configPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func modelsConfigHint(configPath string) string {
|
||||||
|
configPath = strings.TrimSpace(configPath)
|
||||||
|
if configPath == "" {
|
||||||
|
return fmt.Sprintf("Create %s with e.g.:\n%s", modelsConfigTildePath, modelsConfigExample)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Create %s (resolved to %s) with e.g.:\n%s", modelsConfigTildePath, configPath, modelsConfigExample)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadModelsConfig() (*ModelsConfig, error) {
|
||||||
|
configPath, err := modelsConfigPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%w\n\n%s", err, modelsConfigHint(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to read models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg ModelsConfig
|
||||||
|
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
|
||||||
|
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
|
||||||
|
|
||||||
|
// Normalize backend keys so lookups can be case-insensitive.
|
||||||
|
if len(cfg.Backends) > 0 {
|
||||||
|
normalized := make(map[string]BackendConfig, len(cfg.Backends))
|
||||||
|
for k, v := range cfg.Backends {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(k))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized[key] = v
|
||||||
|
}
|
||||||
|
if len(normalized) > 0 {
|
||||||
|
cfg.Backends = normalized
|
||||||
|
} else {
|
||||||
|
cfg.Backends = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
|
||||||
|
if err := ValidateAgentName(name); err != nil {
|
||||||
|
return AgentModelConfig{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(home) == "" {
|
||||||
|
return AgentModelConfig{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
absPath := filepath.Join(home, ".codeagent", "agents", name+".md")
|
||||||
|
info, err := os.Stat(absPath)
|
||||||
|
if err != nil || info.IsDir() {
|
||||||
|
return AgentModelConfig{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return AgentModelConfig{PromptFile: "~/.codeagent/agents/" + name + ".md"}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveBackendConfig(backendName string) (baseURL, apiKey string) {
|
||||||
|
cfg, err := modelsConfig()
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
resolved := resolveBackendConfig(cfg, backendName)
|
||||||
|
return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig {
|
||||||
|
if cfg == nil || len(cfg.Backends) == 0 {
|
||||||
|
return BackendConfig{}
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(backendName))
|
||||||
|
if key == "" {
|
||||||
|
key = strings.ToLower(strings.TrimSpace(cfg.DefaultBackend))
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return BackendConfig{}
|
||||||
|
}
|
||||||
|
if backend, ok := cfg.Backends[key]; ok {
|
||||||
|
return backend
|
||||||
|
}
|
||||||
|
return BackendConfig{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, allowedTools, disallowedTools []string, err error) {
|
||||||
|
if err := ValidateAgentName(agentName); err != nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := modelsConfig()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, err
|
||||||
|
}
|
||||||
|
if cfg == nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("models config is nil\n\n%s", modelsConfigHint(""))
|
||||||
|
}
|
||||||
|
|
||||||
|
if agent, ok := cfg.Agents[agentName]; ok {
|
||||||
|
backend = strings.TrimSpace(agent.Backend)
|
||||||
|
if backend == "" {
|
||||||
|
backend = strings.TrimSpace(cfg.DefaultBackend)
|
||||||
|
if backend == "" {
|
||||||
|
configPath, pathErr := modelsConfigPath()
|
||||||
|
if pathErr != nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(""))
|
||||||
|
}
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
backendCfg := resolveBackendConfig(cfg, backend)
|
||||||
|
|
||||||
|
baseURL = strings.TrimSpace(agent.BaseURL)
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||||
|
}
|
||||||
|
apiKey = strings.TrimSpace(agent.APIKey)
|
||||||
|
if apiKey == "" {
|
||||||
|
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
model = strings.TrimSpace(agent.Model)
|
||||||
|
if model == "" {
|
||||||
|
configPath, pathErr := modelsConfigPath()
|
||||||
|
if pathErr != nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||||
|
}
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
return backend, model, agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo, agent.AllowedTools, agent.DisallowedTools, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dynamic, ok := LoadDynamicAgent(agentName); ok {
|
||||||
|
backend = strings.TrimSpace(cfg.DefaultBackend)
|
||||||
|
model = strings.TrimSpace(cfg.DefaultModel)
|
||||||
|
configPath, pathErr := modelsConfigPath()
|
||||||
|
if backend == "" || model == "" {
|
||||||
|
if pathErr != nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||||
|
}
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
backendCfg := resolveBackendConfig(cfg, backend)
|
||||||
|
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||||
|
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||||
|
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false, nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
configPath, pathErr := modelsConfigPath()
|
||||||
|
if pathErr != nil {
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||||
|
}
|
||||||
|
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, allowedTools, disallowedTools []string, err error) {
|
||||||
|
return resolveAgentConfig(agentName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ResetModelsConfigCacheForTest() {
|
||||||
|
modelsConfigCached = nil
|
||||||
|
modelsConfigErr = nil
|
||||||
|
modelsConfigOnce = sync.Once{}
|
||||||
|
}
|
||||||
262
codeagent-wrapper/internal/config/agent_config_test.go
Normal file
262
codeagent-wrapper/internal/config/agent_config_test.go
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveAgentConfig_NoConfig_ReturnsHelpfulError(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
_, _, _, _, _, _, _, _, _, err := ResolveAgentConfig("develop")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
msg := err.Error()
|
||||||
|
if !strings.Contains(msg, modelsConfigTildePath) {
|
||||||
|
t.Fatalf("error should mention %s, got: %s", modelsConfigTildePath, msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, filepath.Join(home, ".codeagent", "models.json")) {
|
||||||
|
t.Fatalf("error should mention resolved config path, got: %s", msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "\"agents\"") {
|
||||||
|
t.Fatalf("error should include example config, got: %s", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadModelsConfig_NoFile(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
_, err := loadModelsConfig()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
"backends": {
|
||||||
|
"Claude": {
|
||||||
|
"base_url": "https://backend.example",
|
||||||
|
"api_key": "backend-key"
|
||||||
|
},
|
||||||
|
"codex": {
|
||||||
|
"base_url": "https://openai.example",
|
||||||
|
"api_key": "openai-key"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agents": {
|
||||||
|
"custom-agent": {
|
||||||
|
"backend": "codex",
|
||||||
|
"model": "gpt-4o",
|
||||||
|
"description": "Custom agent",
|
||||||
|
"base_url": "https://agent.example",
|
||||||
|
"api_key": "agent-key"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
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)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
cfg, err := loadModelsConfig()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadModelsConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := cfg.Agents["oracle"]; ok {
|
||||||
|
t.Error("oracle should not be present without explicit config")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseURL, apiKey := ResolveBackendConfig("claude")
|
||||||
|
if baseURL != "https://backend.example" {
|
||||||
|
t.Errorf("ResolveBackendConfig(baseURL) = %q, want %q", baseURL, "https://backend.example")
|
||||||
|
}
|
||||||
|
if apiKey != "backend-key" {
|
||||||
|
t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
backend, model, _, _, agentBaseURL, agentAPIKey, _, _, _, err := ResolveAgentConfig("custom-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAgentConfig(custom-agent): %v", err)
|
||||||
|
}
|
||||||
|
if backend != "codex" {
|
||||||
|
t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex")
|
||||||
|
}
|
||||||
|
if model != "gpt-4o" {
|
||||||
|
t.Errorf("ResolveAgentConfig(model) = %q, want %q", model, "gpt-4o")
|
||||||
|
}
|
||||||
|
if agentBaseURL != "https://agent.example" {
|
||||||
|
t.Errorf("ResolveAgentConfig(baseURL) = %q, want %q", agentBaseURL, "https://agent.example")
|
||||||
|
}
|
||||||
|
if agentAPIKey != "agent-key" {
|
||||||
|
t.Errorf("ResolveAgentConfig(apiKey) = %q, want %q", agentAPIKey, "agent-key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAgentConfig_DynamicAgent(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
agentDir := filepath.Join(home, ".codeagent", "agents")
|
||||||
|
if err := os.MkdirAll(agentDir, 0o755); err != nil {
|
||||||
|
t.Fatalf("MkdirAll: %v", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(agentDir, "sarsh.md"), []byte("prompt\n"), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backend, model, promptFile, _, _, _, _, _, _, err := ResolveAgentConfig("sarsh")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAgentConfig(sarsh): %v", err)
|
||||||
|
}
|
||||||
|
if backend != "codex" {
|
||||||
|
t.Errorf("backend = %q, want %q", backend, "codex")
|
||||||
|
}
|
||||||
|
if model != "gpt-test" {
|
||||||
|
t.Errorf("model = %q, want %q", model, "gpt-test")
|
||||||
|
}
|
||||||
|
if promptFile != "~/.codeagent/agents/sarsh.md" {
|
||||||
|
t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
_, err := loadModelsConfig()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAgentConfig_UnknownAgent_ReturnsError(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
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": {
|
||||||
|
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||||
|
}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, _, _, _, err := ResolveAgentConfig("unknown-agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "unknown-agent") {
|
||||||
|
t.Fatalf("error should mention agent name, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveAgentConfig_EmptyModel_ReturnsError(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("USERPROFILE", home)
|
||||||
|
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||||
|
ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
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(`{
|
||||||
|
"agents": {
|
||||||
|
"bad-agent": { "backend": "codex", "model": " " }
|
||||||
|
}
|
||||||
|
}`), 0o644); err != nil {
|
||||||
|
t.Fatalf("WriteFile: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, _, _, _, _, _, _, _, _, err := ResolveAgentConfig("bad-agent")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(err.Error()), "empty model") {
|
||||||
|
t.Fatalf("error should mention empty model, got: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
105
codeagent-wrapper/internal/config/config.go
Normal file
105
codeagent-wrapper/internal/config/config.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"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
|
||||||
|
AllowedTools []string
|
||||||
|
DisallowedTools []string
|
||||||
|
Worktree bool // Execute in a new git worktree
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvFlagEnabled returns true when the environment variable exists and is not
|
||||||
|
// explicitly set to a falsey value ("0/false/no/off").
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxParallelWorkersLimit = 100
|
||||||
|
|
||||||
|
// ResolveMaxParallelWorkers reads CODEAGENT_MAX_PARALLEL_WORKERS. It returns 0
|
||||||
|
// for "unlimited".
|
||||||
|
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 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value > maxParallelWorkersLimit {
|
||||||
|
return maxParallelWorkersLimit
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
47
codeagent-wrapper/internal/config/viper.go
Normal file
47
codeagent-wrapper/internal/config/viper.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewViper returns a viper instance configured for CODEAGENT_* environment
|
||||||
|
// variables and an optional config file.
|
||||||
|
//
|
||||||
|
// Search order when configFile is empty:
|
||||||
|
// - $HOME/.codeagent/config.(yaml|yml|json|toml|...)
|
||||||
|
func NewViper(configFile string) (*viper.Viper, error) {
|
||||||
|
v := viper.New()
|
||||||
|
v.SetEnvPrefix("CODEAGENT")
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
if strings.TrimSpace(configFile) != "" {
|
||||||
|
v.SetConfigFile(configFile)
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
home, err := os.UserHomeDir()
|
||||||
|
if err != nil || strings.TrimSpace(home) == "" {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
v.SetConfigName("config")
|
||||||
|
v.AddConfigPath(filepath.Join(home, ".codeagent"))
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
var notFound viper.ConfigFileNotFoundError
|
||||||
|
if errors.As(err, ¬Found) {
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
196
codeagent-wrapper/internal/executor/env_inject_test.go
Normal file
196
codeagent-wrapper/internal/executor/env_inject_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
backend "codeagent-wrapper/internal/backend"
|
||||||
|
config "codeagent-wrapper/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEnvInjectionWithAgent tests the full flow of env injection with agent config
|
||||||
|
func TestEnvInjectionWithAgent(t *testing.T) {
|
||||||
|
// Setup temp config
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write test config with agent that has base_url and api_key
|
||||||
|
configContent := `{
|
||||||
|
"default_backend": "codex",
|
||||||
|
"agents": {
|
||||||
|
"test-agent": {
|
||||||
|
"backend": "claude",
|
||||||
|
"model": "test-model",
|
||||||
|
"base_url": "https://test.api.com",
|
||||||
|
"api_key": "test-api-key-12345678"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Reset config cache
|
||||||
|
config.ResetModelsConfigCacheForTest()
|
||||||
|
defer config.ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
// Test ResolveAgentConfig
|
||||||
|
agentBackend, model, _, _, baseURL, apiKey, _, _, _, err := config.ResolveAgentConfig("test-agent")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAgentConfig: %v", err)
|
||||||
|
}
|
||||||
|
t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q",
|
||||||
|
agentBackend, model, baseURL, apiKey)
|
||||||
|
|
||||||
|
if agentBackend != "claude" {
|
||||||
|
t.Errorf("expected backend 'claude', got %q", agentBackend)
|
||||||
|
}
|
||||||
|
if baseURL != "https://test.api.com" {
|
||||||
|
t.Errorf("expected baseURL 'https://test.api.com', got %q", baseURL)
|
||||||
|
}
|
||||||
|
if apiKey != "test-api-key-12345678" {
|
||||||
|
t.Errorf("expected apiKey 'test-api-key-12345678', got %q", apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Backend.Env
|
||||||
|
b := backend.ClaudeBackend{}
|
||||||
|
env := b.Env(baseURL, apiKey)
|
||||||
|
t.Logf("Backend.Env: %v", env)
|
||||||
|
|
||||||
|
if env == nil {
|
||||||
|
t.Fatal("expected non-nil env from Backend.Env")
|
||||||
|
}
|
||||||
|
if env["ANTHROPIC_BASE_URL"] != baseURL {
|
||||||
|
t.Errorf("expected ANTHROPIC_BASE_URL=%q, got %q", baseURL, env["ANTHROPIC_BASE_URL"])
|
||||||
|
}
|
||||||
|
if env["ANTHROPIC_API_KEY"] != apiKey {
|
||||||
|
t.Errorf("expected ANTHROPIC_API_KEY=%q, got %q", apiKey, env["ANTHROPIC_API_KEY"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEnvInjectionLogic tests the exact logic used in executor
|
||||||
|
func TestEnvInjectionLogic(t *testing.T) {
|
||||||
|
// Setup temp config
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
configDir := filepath.Join(tmpDir, ".codeagent")
|
||||||
|
if err := os.MkdirAll(configDir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configContent := `{
|
||||||
|
"default_backend": "codex",
|
||||||
|
"agents": {
|
||||||
|
"explore": {
|
||||||
|
"backend": "claude",
|
||||||
|
"model": "MiniMax-M2.1",
|
||||||
|
"base_url": "https://api.minimaxi.com/anthropic",
|
||||||
|
"api_key": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.test"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
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)
|
||||||
|
|
||||||
|
config.ResetModelsConfigCacheForTest()
|
||||||
|
defer config.ResetModelsConfigCacheForTest()
|
||||||
|
|
||||||
|
// Simulate the executor logic
|
||||||
|
cfgBackend := "claude" // This should come from taskSpec.Backend
|
||||||
|
agentName := "explore"
|
||||||
|
|
||||||
|
// Step 1: Get backend config (usually empty for claude without global config)
|
||||||
|
baseURL, apiKey := config.ResolveBackendConfig(cfgBackend)
|
||||||
|
t.Logf("Step 1 - ResolveBackendConfig(%q): baseURL=%q, apiKey=%q", cfgBackend, baseURL, apiKey)
|
||||||
|
|
||||||
|
// Step 2: If agent specified, get agent config
|
||||||
|
if agentName != "" {
|
||||||
|
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, _, _, err := config.ResolveAgentConfig(agentName)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolveAgentConfig(%q): %v", agentName, err)
|
||||||
|
}
|
||||||
|
t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q",
|
||||||
|
agentName, agentBackend, agentBaseURL, agentAPIKey)
|
||||||
|
|
||||||
|
// Step 3: Check if agent backend matches cfg backend
|
||||||
|
if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfgBackend)) {
|
||||||
|
baseURL, apiKey = agentBaseURL, agentAPIKey
|
||||||
|
t.Logf("Step 3 - Backend match! Using agent config: baseURL=%q, apiKey=%q", baseURL, apiKey)
|
||||||
|
} else {
|
||||||
|
t.Logf("Step 3 - Backend mismatch: agent=%q, cfg=%q", agentBackend, cfgBackend)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Get env vars from backend
|
||||||
|
b := backend.ClaudeBackend{}
|
||||||
|
injected := b.Env(baseURL, apiKey)
|
||||||
|
t.Logf("Step 4 - Backend.Env: %v", injected)
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
if len(injected) == 0 {
|
||||||
|
t.Fatal("Expected env vars to be injected, got none")
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedURL := "https://api.minimaxi.com/anthropic"
|
||||||
|
if injected["ANTHROPIC_BASE_URL"] != expectedURL {
|
||||||
|
t.Errorf("ANTHROPIC_BASE_URL: expected %q, got %q", expectedURL, injected["ANTHROPIC_BASE_URL"])
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := injected["ANTHROPIC_API_KEY"]; !ok {
|
||||||
|
t.Error("ANTHROPIC_API_KEY not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Test masking
|
||||||
|
for k, v := range injected {
|
||||||
|
masked := maskSensitiveValue(k, v)
|
||||||
|
t.Logf("Step 5 - Env log: %s=%s", k, masked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTaskSpecBackendPropagation tests that taskSpec.Backend is properly used
|
||||||
|
func TestTaskSpecBackendPropagation(t *testing.T) {
|
||||||
|
// Simulate what happens in RunCodexTaskWithContext
|
||||||
|
taskSpec := TaskSpec{
|
||||||
|
ID: "test",
|
||||||
|
Task: "hello",
|
||||||
|
Backend: "claude",
|
||||||
|
Agent: "explore",
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the logic from executor.go lines 889-916
|
||||||
|
cfg := &config.Config{
|
||||||
|
Mode: "new",
|
||||||
|
Task: taskSpec.Task,
|
||||||
|
Backend: "codex", // default
|
||||||
|
}
|
||||||
|
|
||||||
|
var backend Backend = nil // nil in single mode
|
||||||
|
commandName := "codex" // default
|
||||||
|
|
||||||
|
if backend != nil {
|
||||||
|
cfg.Backend = backend.Name()
|
||||||
|
} else if taskSpec.Backend != "" {
|
||||||
|
cfg.Backend = taskSpec.Backend
|
||||||
|
} else if commandName != "" {
|
||||||
|
cfg.Backend = commandName
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("taskSpec.Backend=%q, cfg.Backend=%q", taskSpec.Backend, cfg.Backend)
|
||||||
|
|
||||||
|
if cfg.Backend != "claude" {
|
||||||
|
t.Errorf("expected cfg.Backend='claude', got %q", cfg.Backend)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user