Compare commits

...

47 Commits

Author SHA1 Message Date
cexll
a09c103cfb fix(codeagent): 防止 Claude backend 无限递归调用
通过设置 --setting-sources="" 禁用所有配置源(user, project, local),
避免被调用的 Claude 实例加载 ~/.claude/CLAUDE.md 和 skills,
从而防止再次调用 codeagent 导致的循环超时问题。

修改内容:
- backend.go: ClaudeBackend.BuildArgs 添加 --setting-sources="" 参数
- backend_test.go: 更新 4 个测试用例以匹配新的参数列表
- main_test.go: 更新 2 个测试用例以匹配新的参数列表

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-16 10:27:21 +08:00
swe-agent[bot]
b3f8fcfea6 update CHANGELOG.md 2025-12-15 22:23:34 +08:00
ben
806bb04a35 Merge pull request #65 from cexll/fix/issue-64-buffer-overflow
fix(parser): 修复 bufio.Scanner token too long 错误
2025-12-15 14:22:03 +08:00
swe-agent[bot]
b1156038de test: 同步测试中的版本号至 5.2.3
修复 CI 失败:将 main_test.go 中的版本期望值从 5.2.2 更新为 5.2.3,
与 main.go 中的实际版本号保持一致。

修改文件:
- codeagent-wrapper/main_test.go:2693 (TestVersionFlag)
- codeagent-wrapper/main_test.go:2707 (TestVersionShortFlag)
- codeagent-wrapper/main_test.go:2721 (TestVersionLegacyAlias)

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-15 14:13:03 +08:00
swe-agent[bot]
0c93bbe574 change version 2025-12-15 13:23:26 +08:00
swe-agent[bot]
6f4f4e701b fix(parser): 修复 bufio.Scanner token too long 错误 (#64)
## 问题
- 执行 rg 等命令时,如果匹配到 minified 文件,单行输出可能超过 10MB
- 旧实现使用 bufio.Scanner,遇到超长行会报错并中止整个解析
- 导致后续的 agent_message 无法读取,任务失败

## 修复
1. **parser.go**:
   - 移除 bufio.Scanner,改用 bufio.Reader + readLineWithLimit
   - 超长行(>10MB)会被跳过但继续处理后续事件
   - 添加 codexHeader 轻量级解析,只在 agent_message 时完整解析

2. **utils.go**:
   - 修复 logWriter 内存膨胀问题
   - 添加 writeLimited 方法限制缓冲区大小

3. **测试**:
   - parser_token_too_long_test.go: 验证超长行处理
   - log_writer_limit_test.go: 验证日志缓冲限制

## 测试结果
-  TestParseJSONStream_SkipsOverlongLineAndContinues
-  TestLogWriterWriteLimitsBuffer
-  完整测试套件通过

Fixes #64

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-15 13:19:51 +08:00
swe-agent[bot]
ff301507fe test: Fix tests for ClaudeBackend default --dangerously-skip-permissions
- Update TestClaudeBuildArgs_ModesAndPermissions expectations
- Update TestBackendBuildArgs_ClaudeBackend expectations
- Update TestClaudeBackendBuildArgs_OutputValidation expectations
- Update version tests to expect 5.2.2

ClaudeBackend now defaults to adding --dangerously-skip-permissions
for automation workflows.

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 21:53:38 +08:00
swe-agent[bot]
93b72eba42 chore(v5.2.2): Bump version and clean up documentation
- Update version to 5.2.2 in README.md, README_CN.md, and codeagent-wrapper/main.go
- Remove non-existent documentation links from README.md (architecture.md, GITHUB-WORKFLOW.md, enterprise-workflow-ideas.md)
- Add coverage.out to .gitignore

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 21:43:49 +08:00
swe-agent[bot]
b01758e7e1 fix codeagent backend claude no auto 2025-12-13 21:42:17 +08:00
swe-agent[bot]
c51b38c671 fix install.py dev fail 2025-12-13 21:41:55 +08:00
swe-agent[bot]
b227fee225 fix codeagent claude and gemini root dir 2025-12-13 16:56:53 +08:00
swe-agent[bot]
2b7569335b update readme 2025-12-13 15:29:12 +08:00
swe-agent[bot]
9e667f0895 feat(v5.2.0): Complete skills system integration and config cleanup
Core Changes:
- **Skills System**: Added codeagent, product-requirements, prototype-prompt-generator to skill-rules.json
- **Config Cleanup**: Removed deprecated gh module from config.json
- **Workflow Update**: Changed memorys/CLAUDE.md to use codeagent skill instead of codex

Details:
- config.json:88-119: Removed gh module (github-workflow directory doesn't exist)
- skills/skill-rules.json:24-114: Added 3 new skills with keyword/pattern triggers
  - codeagent: multi-backend, parallel task execution
  - product-requirements: PRD, requirements gathering
  - prototype-prompt-generator: UI/UX design specifications
- memorys/CLAUDE.md:3,24-25: Updated Codex skill → Codeagent skill

Verification:
- All skill activation tests PASS
- codeagent skill triggers correctly on keyword match

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 13:25:21 +08:00
swe-agent[bot]
4759eb2c42 chore(v5.2.0): Update CHANGELOG and remove deprecated test files
- Added Skills System Enhancements section to CHANGELOG
- Documented new skills: codeagent, product-requirements, prototype-prompt-generator
- Removed deprecated test files (tests/test_*.py)
- Updated release date to 2025-12-13

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 13:21:59 +08:00
swe-agent[bot]
edbf168b57 fix(codeagent-wrapper): fix race condition in stdout parsing
修复 GitHub Actions CI 中的测试失败问题。

问题分析:
在 TestRun_PipedTaskSuccess 测试中,当脚本运行很快时,cmd.Wait()
可能在 parseJSONStreamInternal goroutine 开始读取之前就返回,
导致 stdout 管道被过早关闭,出现 "read |0: file already closed" 错误。

解决方案:
将 parseJSONStreamInternal goroutine 的启动提前到 cmd.Start() 之前。
这确保解析器在进程启动前就 ready,避免竞态条件。

测试结果:
- 本地所有测试通过 ✓
- 覆盖率保持 93.7% ✓

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 13:20:49 +08:00
swe-agent[bot]
9bfea81ca6 docs(changelog): remove GitHub workflow related content
GitHub workflow features have been removed from the project.

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 13:01:06 +08:00
swe-agent[bot]
a9bcea45f5 Merge rc/5.2 into master: v5.2.0 release improvements 2025-12-13 12:56:37 +08:00
swe-agent[bot]
8554da6e2f feat(v5.2.0): Improve release notes and installation scripts
**Main Changes**:
1. release.yml: Extract version release notes from CHANGELOG.md
2. install.bat: codex-wrapper → codeagent-wrapper
3. README.md: Update multi-backend architecture description
4. README_CN.md: Sync Chinese description
5. CHANGELOG.md: Complete v5.2.0 release notes in English

Generated with swe-agent-bot

Co-Authored-By: swe-agent-bot <agent@swe-agent.ai>
2025-12-13 12:53:28 +08:00
ben
b2f941af5f Merge pull request #53 from cexll/rc/5.2
feat: Enterprise Workflow with Multi-Backend Support (v5.2)
2025-12-13 12:38:38 +08:00
swe-agent[bot]
6861a9d057 remove docs 2025-12-13 12:37:45 +08:00
swe-agent[bot]
18189f095c remove docs 2025-12-13 12:36:12 +08:00
swe-agent[bot]
f1c306cb23 add prototype prompt skill 2025-12-13 12:33:02 +08:00
swe-agent[bot]
0dc6df4e71 add prd skill 2025-12-13 12:32:37 +08:00
swe-agent[bot]
21bb45a7af update memory claude 2025-12-13 12:32:15 +08:00
swe-agent[bot]
e7464d1286 remove command gh flow 2025-12-13 12:32:06 +08:00
swe-agent[bot]
373d75cc36 update license 2025-12-13 12:31:49 +08:00
swe-agent[bot]
0bbcc6c68e fix(codeagent-wrapper): add worker limit cap and remove legacy alias
- Add maxParallelWorkersLimit=100 cap for CODEAGENT_MAX_PARALLEL_WORKERS
- Remove scripts/install.sh (codex-wrapper legacy alias no longer needed)
- Fix README command example: /gh-implement -> /gh-issue-implement
- Add TestResolveMaxParallelWorkers unit test for limit validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 22:06:23 +08:00
swe-agent[bot]
3c6f22ca48 fix(codeagent-wrapper): use -r flag for gemini backend resume
Gemini CLI uses -r <session_id> for session resume, not --session-id.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 15:35:39 +08:00
swe-agent[bot]
87016ce331 fix(install): clarify module list shows default state not enabled
Renamed "Enabled" column to "Default" and added hint explaining
the meaning of the checkmark.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 15:14:41 +08:00
swe-agent[bot]
86d18ca19a fix(codeagent-wrapper): use -r flag for claude backend resume
Claude CLI uses `-r <session_id>` for resume, not `--session-id`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 15:10:17 +08:00
swe-agent[bot]
4edd2d2d2d fix(codeagent-wrapper): remove binary artifacts and improve error messages
- Remove committed binaries from git tracking (codeagent-wrapper, *.test)
- Remove coverage.out from tracking (generated by CI)
- Update .gitignore to exclude build artifacts
- Add task block index to parallel config error messages for better debugging

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 14:41:54 +08:00
swe-agent[bot]
ef47ed57e9 test(codeagent-wrapper): 添加 ExtractRecentErrors 单元测试
测试覆盖:
- 空日志文件
- 无错误日志
- 单个错误
- ERROR 和 WARN 混合
- maxEntries 截断
- nil logger
- 空路径
- 不存在的文件

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 14:27:50 +08:00
swe-agent[bot]
b2e3f416bc fix(codeagent-wrapper): 异常退出时显示最近错误信息
- 添加 Logger.ExtractRecentErrors() 方法提取最近 ERROR/WARN 日志
- 修改退出逻辑:失败时先输出错误再删除日志文件

Closes #56

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 14:25:22 +08:00
swe-agent[bot]
7231c6d2c4 fix(install): op_run_command 实时流式输出
- 使用 Popen + selectors 替代 subprocess.run(capture_output=True)
- stdout/stderr 实时打印到终端,同时记录到日志
- 用户可以看到命令执行的实时进度
- 修复 issue #55: bash install.sh 执行过程不可见的问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 13:22:00 +08:00
swe-agent[bot]
fa342f98c2 feat(install): 添加终端日志输出和 verbose 模式
- 新增 --verbose/-v 参数启用详细日志输出
- 安装过程中显示模块进度 [n/total]
- 安装完成后显示摘要统计
- write_log 在 verbose 模式下同时输出到终端
- 修复 issue #55: 方便 debug 安装脚本执行问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-12 11:55:51 +08:00
swe-agent[bot]
90478d2049 fix(codeagent-wrapper): 修复权限标志逻辑和版本号测试
修复 GitHub Action CI 失败的两个问题:

1. backend.go - 修正 Claude 后端权限标志逻辑,将 `if !cfg.SkipPermissions` 改为 `if cfg.SkipPermissions`,确保只在显式请求时才添加 --dangerously-skip-permissions
2. main_test.go - 更新版本测试用例期望值从 5.1.0 到 5.2.0,匹配当前版本常量

所有测试通过 ✓

🤖 Generated with [SWE Agent Bot](https://swe-agent.ai)

Co-Authored-By: SWE-Agent-Bot <noreply@swe-agent.ai>
2025-12-11 16:16:23 +08:00
swe-agent[bot]
e1ad08fcc1 feat(codeagent-wrapper): 完整多后端支持与安全优化
修复 PR #53 中发现的问题,实现完整的多后端功能:

**多后端功能完整性**
- Claude/Gemini 后端支持 workdir (-C) 和 resume (--session-id) 参数
- 并行模式支持全局 --backend 参数和任务级 backend 配置
- 后端参数映射统一,支持 new/resume 两种模式

**安全控制**
- Claude 后端默认启用 --dangerously-skip-permissions 以支持自动化
- 通过 CODEAGENT_SKIP_PERMISSIONS 环境变量控制权限检查
- 不同后端行为区分:Claude 默认跳过,Codex/Gemini 默认启用

**并发控制**
- 新增 CODEAGENT_MAX_PARALLEL_WORKERS 环境变量限制并发数
- 实现 fail-fast context 取消机制
- Worker pool 防止资源耗尽,支持并发监控日志

**向后兼容**
- 版本号统一管理,提供 codex-wrapper 兼容脚本
- 所有默认行为保持不变
- 支持渐进式迁移

**测试覆盖**
- 总体覆盖率 93.4%(超过 90% 要求)
- 新增后端参数、并行模式、并发控制测试用例
- 核心模块覆盖率:backend.go 100%, config.go 97.8%, executor.go 96.4%

**文档更新**
- 更新 skills/codeagent/SKILL.md 反映多后端和安全控制
- 添加 CHANGELOG.md 记录重要变更
- 更新 README 版本说明和安装脚本

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-11 16:09:33 +08:00
swe-agent[bot]
cf2e4fefa4 fix(codeagent-wrapper): 重构信号处理逻辑避免重复 nil 检查
改进信号转发函数的可读性和可维护性:
- 提取 signalNotifyFn 和 signalStopFn 的默认值设置
- 消除嵌套的 nil 检查
- 保持相同的功能行为

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 16:29:32 +08:00
swe-agent[bot]
d7bb28a9ce feat(dev-workflow): 替换 Codex 为 codeagent 并添加 UI 自动检测
主要变更:
- 全量替换 Codex → codeagent skill 引用
- 添加 UI 自动检测机制(Step 2 分析阶段)
- 实现 backend 分流:后端任务用 codex,UI 任务用 gemini
- 修正 agent 名称:develop-doc-generator → dev-plan-generator
- 更新命令格式为实际的 codeagent-wrapper API
- 放宽 UI 判断标准:样式文件 OR 前端组件(覆盖更多场景)

文件变更:
- dev-workflow/commands/dev.md: 更新 6 步工作流定义
- dev-workflow/README.md: 更新文档和示例
- dev-workflow/agents/dev-plan-generator.md: 更新输入参数说明

保持向后兼容:
- 6 步工作流结构不变
- 90% 测试覆盖率要求不变

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 16:29:11 +08:00
swe-agent[bot]
b41b223fc8 refactor(pr-53): 调整文件命名和技能定义
1. 回滚 skills/codex/SKILL.md 至使用 codex-wrapper
   - codeagent-wrapper 已由独立技能 skills/codeagent/SKILL.md 提供
   - 保持向后兼容性和职责分离

2. 重命名命令文件为语义化名称
   - gh-implement.md → gh-issue-implement.md
   - 更新命令标识从 /gh-implement 到 /gh-issue-implement
   - 提升命令意图的清晰度

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 17:19:23 +08:00
swe-agent[bot]
a86ee9340c fix(ci): 移除 .claude 配置文件验证步骤
.claude/ 目录在 .gitignore 中被忽略,这些用户特定配置文件
不应该存在于仓库中,也不需要被 CI 检查。

Fixes #53

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 17:10:18 +08:00
swe-agent[bot]
c6cd20d2fd fix(parallel): 修复并行执行启动横幅重复打印问题
修复 GitHub Actions 失败的测试 TestRunParallelStartupLogsPrinted。

问题根源:
- 在 main.go 中有重复的启动横幅和日志路径打印逻辑
- executeConcurrent 内部也添加了相同的打印逻辑
- 导致横幅和任务日志被打印两次

修复内容:
1. 删除 main.go 中 --parallel 处理中的重复打印代码(行 184-194)
2. 保留 executeConcurrent 中的 printTaskStart 函数,实现:
   - 在任务启动时立即打印日志路径
   - 使用 mutex 保护并发打印,确保横幅只打印一次
   - 按实际执行顺序打印任务信息

测试结果:
- TestRunParallelStartupLogsPrinted: PASS
- TestRunNonParallelOutputsIncludeLogPathsIntegration: PASS
- TestRunStartupCleanupRemovesOrphansEndToEnd: PASS

影响范围:
- 修复了 --parallel 模式下的日志输出格式
- 不影响非并行模式的执行

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 17:02:59 +08:00
swe-agent[bot]
132df6cb28 fix(merge): 修复master合并后的编译和测试问题
在重构代码后合并master分支时需要的适配:

1. **接口定义恢复** (executor.go)
   - 添加 commandRunner 和 processHandle 接口
   - 实现 realCmd 和 realProcess 适配器
   - 添加 newCommandRunner 测试钩子

2. **TaskResult扩展** (config.go)
   - 添加 LogPath 字段支持日志路径跟踪
   - 在 generateFinalOutput 中输出 LogPath

3. **原子变量适配** (main.go, executor.go)
   - forceKillDelay 从int改为 atomic.Int32
   - 添加测试钩子: cleanupLogsFn, signalNotifyFn, signalStopFn
   - 添加 stdout 关闭原因常量

4. **功能函数添加**
   - runStartupCleanup: 启动时清理旧日志
   - runCleanupMode: --cleanup 模式处理
   - forceKillTimer 类型和 terminateCommand 函数
   - terminateProcess nil 安全检查

5. **测试适配** (logger_test.go, main_test.go)
   - 将 *exec.Cmd 包装为 &realCmd{cmd}
   - 修复 forwardSignals 等函数调用

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 16:24:15 +08:00
swe-agent[bot]
d7c514e869 Merge branch 'master' into rc/5.2
解决合并冲突:
- .github/workflows/release.yml: 统一使用codeagent-wrapper目录和输出名称
- codeagent-wrapper/main_test.go: 合并HEAD的新测试(Claude/Gemini事件解析)和master的测试改进
- 接受master的新文件: .gitignore, process_check_* 到codeagent-wrapper目录
- 确认删除: codex-wrapper/main.go (已重命名)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 16:03:44 +08:00
swe-agent[bot]
3ef288bfaa feat: implement enterprise workflow with multi-backend support
## Overview
Complete implementation of enterprise-level workflow features including
multi-backend execution (Codex/Claude/Gemini), GitHub issue-to-PR automation,
hooks system, and comprehensive documentation.

## Major Changes

### 1. Multi-Backend Support (codeagent-wrapper)
- Renamed codex-wrapper → codeagent-wrapper
- Backend interface with Codex/Claude/Gemini implementations
- Multi-format JSON stream parser (auto-detects backend)
- CLI flag: --backend codex|claude|gemini (default: codex)
- Test coverage: 89.2%

**Files:**
- codeagent-wrapper/backend.go - Backend interface
- codeagent-wrapper/parser.go - Multi-format parser
- codeagent-wrapper/config.go - CLI parsing with backend selection
- codeagent-wrapper/executor.go - Process execution
- codeagent-wrapper/logger.go - Async logging
- codeagent-wrapper/utils.go - Utilities

### 2. GitHub Workflow Commands
- /gh-create-issue - Create structured issues via guided dialogue
- /gh-implement - Issue-to-PR automation with full dev lifecycle

**Files:**
- github-workflow/commands/gh-create-issue.md
- github-workflow/commands/gh-implement.md
- skills/codeagent/SKILL.md

### 3. Hooks System
- UserPromptSubmit hook for skill activation
- Pre-commit example with code quality checks
- merge_json operation in install.py for settings.json merging

**Files:**
- hooks/skill-activation-prompt.sh|.js
- hooks/pre-commit.sh
- hooks/hooks-config.json
- hooks/test-skill-activation.sh

### 4. Skills System
- skill-rules.json for auto-activation
- codeagent skill for multi-backend wrapper

**Files:**
- skills/skill-rules.json
- skills/codeagent/SKILL.md
- skills/codex/SKILL.md (updated)

### 5. Installation System
- install.py: Added merge_json operation
- config.json: Added "gh" module
- config.schema.json: Added op_merge_json schema

### 6. CI/CD
- GitHub Actions workflow for testing and building

**Files:**
- .github/workflows/ci.yml

### 7. Comprehensive Documentation
- Architecture overview with ASCII diagrams
- Codeagent-wrapper complete usage guide
- GitHub workflow detailed examples
- Hooks customization guide

**Files:**
- docs/architecture.md (21KB)
- docs/CODEAGENT-WRAPPER.md (9KB)
- docs/GITHUB-WORKFLOW.md (9KB)
- docs/HOOKS.md (4KB)
- docs/enterprise-workflow-ideas.md
- README.md (updated with doc links)

## Test Results
- All tests passing 
- Coverage: 89.2%
- Security scan: 0 issues (gosec)

## Breaking Changes
- codex-wrapper renamed to codeagent-wrapper
- Default backend: codex (documented in README)

## Migration Guide
Users with codex-wrapper installed should:
1. Run: python3 install.py --module dev --force
2. Update shell aliases if any

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 15:53:31 +08:00
ben
d86a5b67b6 Merge pull request #52 from cexll/fix/parallel-log-path-on-startup
fix(parallel): 任务启动时立即返回日志文件路径以支持实时调试
2025-12-09 11:26:19 +08:00
swe-agent[bot]
8f3941adae fix(parallel): 任务启动时立即返回日志文件路径以支持实时调试
修复 --parallel 模式下日志路径在任务完成后才显示的问题,导致长时间运行任务无法实时查看日志进行调试。

主要改进:
- 在 executeConcurrent 中任务启动时立即输出日志路径到 stderr
- 使用 sync.Mutex 保护并发输出,避免多任务输出行交错
- 添加 "=== Starting Parallel Execution ===" banner 标识执行开始
- 扩展 TaskResult 结构体添加 LogPath 字段,确保最终总结仍包含路径
- 统一 parallel 和非 parallel 模式的日志路径输出行为

测试覆盖:
- 总体覆盖率提升至 91.0%
- 核心函数 executeConcurrent 达到 97.8% 覆盖
- 新增集成测试验证启动日志输出、依赖跳过、并发安全等场景

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-09 11:19:25 +08:00
59 changed files with 9411 additions and 2654 deletions

34
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [master, rc/*]
pull_request:
branches: [master, rc/*]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
- name: Run tests
run: |
cd codeagent-wrapper
go test -v -cover -coverprofile=coverage.out ./...
- name: Check coverage
run: |
cd codeagent-wrapper
go tool cover -func=coverage.out | grep total | awk '{print $3}'
- name: Upload coverage
uses: codecov/codecov-action@v4
with:
file: codeagent-wrapper/coverage.out
continue-on-error: true

View File

@@ -1,4 +1,4 @@
name: Release codex-wrapper
name: Release codeagent-wrapper
on:
push:
@@ -22,11 +22,11 @@ jobs:
go-version: '1.21'
- name: Run tests
working-directory: codex-wrapper
working-directory: codeagent-wrapper
run: go test -v -coverprofile=cover.out ./...
- name: Check coverage
working-directory: codex-wrapper
working-directory: codeagent-wrapper
run: |
go tool cover -func=cover.out | grep total
COVERAGE=$(go tool cover -func=cover.out | grep total | awk '{print $3}' | sed 's/%//')
@@ -63,25 +63,25 @@ jobs:
- name: Build binary
id: build
working-directory: codex-wrapper
working-directory: codeagent-wrapper
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
VERSION=${GITHUB_REF#refs/tags/}
OUTPUT_NAME=codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
OUTPUT_NAME=codeagent-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
if [ "${{ matrix.goos }}" = "windows" ]; then
OUTPUT_NAME="${OUTPUT_NAME}.exe"
fi
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} .
chmod +x ${OUTPUT_NAME}
echo "artifact_path=codex-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: codex-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
name: codeagent-wrapper-${{ matrix.goos }}-${{ matrix.goarch }}
path: ${{ steps.build.outputs.artifact_path }}
release:
@@ -100,14 +100,42 @@ jobs:
- name: Prepare release files
run: |
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 install.bat release/
ls -la release/
- name: Extract release notes from CHANGELOG
id: extract_notes
run: |
VERSION=${GITHUB_REF#refs/tags/v}
# Extract version section from CHANGELOG.md
awk -v ver="$VERSION" '
/^## [0-9]+\.[0-9]+\.[0-9]+ - / {
if (found) exit
if ($2 == ver) {
found = 1
next
}
}
found && /^## / { exit }
found { print }
' CHANGELOG.md > release_notes.md
# Fallback to auto-generated if extraction failed
if [ ! -s release_notes.md ]; then
echo "⚠️ No release notes found in CHANGELOG.md for version $VERSION" > release_notes.md
echo "" >> release_notes.md
echo "## What's Changed" >> release_notes.md
echo "See commits in this release for details." >> release_notes.md
fi
cat release_notes.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
files: release/*
generate_release_notes: true
body_path: release_notes.md
draft: false
prerelease: false

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@
.pytest_cache
__pycache__
.coverage
coverage.out

198
CHANGELOG.md Normal file
View File

@@ -0,0 +1,198 @@
# Changelog
All notable changes to this project will be documented in this file.
## [5.2.3] - 2025-12-15
### 🐛 Bug Fixes
- *(parser)* 修复 bufio.Scanner token too long 错误 (#64)
### 🧪 Testing
- 同步测试中的版本号至 5.2.3
## [5.2.2] - 2025-12-13
### 🧪 Testing
- Fix tests for ClaudeBackend default --dangerously-skip-permissions
### ⚙️ Miscellaneous Tasks
- *(v5.2.2)* Bump version and clean up documentation
## [5.2.0] - 2025-12-13
### 🚀 Features
- *(dev-workflow)* 替换 Codex 为 codeagent 并添加 UI 自动检测
- *(codeagent-wrapper)* 完整多后端支持与安全优化
- *(install)* 添加终端日志输出和 verbose 模式
- *(v5.2.0)* Improve release notes and installation scripts
- *(v5.2.0)* Complete skills system integration and config cleanup
### 🐛 Bug Fixes
- *(merge)* 修复master合并后的编译和测试问题
- *(parallel)* 修复并行执行启动横幅重复打印问题
- *(ci)* 移除 .claude 配置文件验证步骤
- *(codeagent-wrapper)* 重构信号处理逻辑避免重复 nil 检查
- *(codeagent-wrapper)* 修复权限标志逻辑和版本号测试
- *(install)* Op_run_command 实时流式输出
- *(codeagent-wrapper)* 异常退出时显示最近错误信息
- *(codeagent-wrapper)* Remove binary artifacts and improve error messages
- *(codeagent-wrapper)* Use -r flag for claude backend resume
- *(install)* Clarify module list shows default state not enabled
- *(codeagent-wrapper)* Use -r flag for gemini backend resume
- *(codeagent-wrapper)* Add worker limit cap and remove legacy alias
- *(codeagent-wrapper)* Fix race condition in stdout parsing
### 🚜 Refactor
- *(pr-53)* 调整文件命名和技能定义
### 📚 Documentation
- *(changelog)* Remove GitHub workflow related content
### 🧪 Testing
- *(codeagent-wrapper)* 添加 ExtractRecentErrors 单元测试
### ⚙️ Miscellaneous Tasks
- *(v5.2.0)* Update CHANGELOG and remove deprecated test files
## [5.1.4] - 2025-12-09
### 🐛 Bug Fixes
- *(parallel)* 任务启动时立即返回日志文件路径以支持实时调试
## [5.1.3] - 2025-12-08
### 🐛 Bug Fixes
- *(test)* Resolve CI timing race in TestFakeCmdInfra
## [5.1.2] - 2025-12-08
### 🐛 Bug Fixes
- 修复channel同步竞态条件和死锁问题
## [5.1.1] - 2025-12-08
### 🐛 Bug Fixes
- *(test)* Resolve data race on forceKillDelay with atomic operations
- 增强日志清理的安全性和可靠性
### 💼 Other
- Resolve signal handling conflict preserving testability and Windows support
### 🧪 Testing
- 补充测试覆盖提升至 89.3%
## [5.1.0] - 2025-12-07
### 🚀 Features
- Implement enterprise workflow with multi-backend support
- *(cleanup)* 添加启动时清理日志的功能和--cleanup标志支持
## [5.0.0] - 2025-12-05
### 🚀 Features
- Implement modular installation system
### 🐛 Bug Fixes
- *(codex-wrapper)* Defer startup log until args parsed
### 🚜 Refactor
- Remove deprecated plugin modules
### 📚 Documentation
- Rewrite documentation for v5.0 modular architecture
### ⚙️ Miscellaneous Tasks
- Clarify unit-test coverage levels in requirement questions
## [4.8.2] - 2025-12-02
### 🐛 Bug Fixes
- *(codex-wrapper)* Capture and include stderr in error messages
- Correct Go version in go.mod from 1.25.3 to 1.21
- Make forceKillDelay testable to prevent signal test timeout
- Skip signal test in CI environment
## [4.8.1] - 2025-12-01
### 🐛 Bug Fixes
- *(codex-wrapper)* Improve --parallel parameter validation and docs
### 🎨 Styling
- *(codex-skill)* Replace emoji with text labels
## [4.7.3] - 2025-11-29
### 🚀 Features
- Add async logging to temp file with lifecycle management
- Add parallel execution support to codex-wrapper
- Add session resume support and improve output format
### 🐛 Bug Fixes
- *(logger)* 保留日志文件以便程序退出后调试并完善日志输出功能
### 📚 Documentation
- Improve codex skill parameter best practices
## [4.7.2] - 2025-11-28
### 🐛 Bug Fixes
- *(main)* Improve buffer size and streamline message extraction
### 🧪 Testing
- *(ParseJSONStream)* 增加对超大单行文本和非字符串文本的处理测试
## [4.7] - 2025-11-27
### 🐛 Bug Fixes
- Update repository URLs to cexll/myclaude
## [4.4] - 2025-11-22
### 🚀 Features
- 支持通过环境变量配置 skills 模型
## [4.1] - 2025-11-04
### 📚 Documentation
- 新增 /enhance-prompt 命令并更新所有 README 文档
## [3.1] - 2025-09-17
### 💼 Other
- Sync READMEs with actual commands/agents; remove nonexistent commands; enhance requirements-pilot with testing decision gate and options.
<!-- generated by git-cliff -->

661
LICENSE Normal file
View 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/>.

View File

@@ -1,27 +1,29 @@
[中文](README_CN.md) [English](README.md)
# Claude Code Multi-Agent Workflow System
[![Run in Smithery](https://smithery.ai/badge/skills/cexll)](https://smithery.ai/skills?ns=cexll&utm_source=github&utm_medium=badge)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code)
[![Version](https://img.shields.io/badge/Version-5.0-green)](https://github.com/cexll/myclaude)
[![Version](https://img.shields.io/badge/Version-5.2.2-green)](https://github.com/cexll/myclaude)
> AI-powered development automation with Claude Code + Codex collaboration
> AI-powered development automation with multi-backend execution (Codex/Claude/Gemini)
## Core Concept: Claude Code + Codex
## Core Concept: Multi-Backend Architecture
This system leverages a **dual-agent architecture**:
This system leverages a **dual-agent architecture** with pluggable AI backends:
| Role | Agent | Responsibility |
|------|-------|----------------|
| **Orchestrator** | Claude Code | Planning, context gathering, verification, user interaction |
| **Executor** | Codex | Code editing, test execution, file operations |
| **Executor** | codeagent-wrapper | Code editing, test execution (Codex/Claude/Gemini backends) |
**Why this separation?**
- Claude Code excels at understanding context and orchestrating complex workflows
- Codex excels at focused code generation and execution
- Together they provide better results than either alone
- Specialized backends (Codex for code, Claude for reasoning, Gemini for prototyping) excel at focused execution
- Backend selection via `--backend codex|claude|gemini` matches the model to the task
## Quick Start(Please execute in Powershell on Windows)
@@ -122,6 +124,12 @@ Requirements → Architecture → Sprint Plan → Development → Review → QA
**Best For:** Quick tasks, no workflow overhead needed
## Enterprise Workflow Features
- **Multi-backend execution:** `codeagent-wrapper --backend codex|claude|gemini` (default `codex`) so you can match the model to the task without changing workflows.
- **GitHub workflow commands:** `/gh-create-issue "short need"` creates structured issues; `/gh-issue-implement 123` pulls issue #123, drives development, and prepares the PR.
- **Skills + hooks activation:** .claude/hooks run automation (tests, reviews), while `.claude/skills/skill-rules.json` auto-suggests the right skills. Keep hooks enabled in `.claude/settings.json` to activate the enterprise workflow helpers.
---
## Installation
@@ -204,7 +212,7 @@ The `codex` skill enables Claude Code to delegate code execution to Codex CLI.
```bash
# Codex is invoked via the skill
codex-wrapper - <<'EOF'
codeagent-wrapper - <<'EOF'
implement @src/auth.ts with JWT validation
EOF
```
@@ -212,7 +220,7 @@ EOF
### Parallel Execution
```bash
codex-wrapper --parallel <<'EOF'
codeagent-wrapper --parallel <<'EOF'
---TASK---
id: backend_api
workdir: /project/backend
@@ -240,7 +248,7 @@ bash install.sh
#### Windows
Windows installs place `codex-wrapper.exe` in `%USERPROFILE%\bin`.
Windows installs place `codeagent-wrapper.exe` in `%USERPROFILE%\bin`.
```powershell
# PowerShell (recommended)
@@ -309,9 +317,20 @@ python3 install.py --module dev --force
---
## Documentation
### Core Guides
- **[Codeagent-Wrapper Guide](docs/CODEAGENT-WRAPPER.md)** - Multi-backend execution wrapper
- **[Hooks Documentation](docs/HOOKS.md)** - Custom hooks and automation
### Additional Resources
- **[Installation Log](install.log)** - Installation history and troubleshooting
---
## License
MIT License - see [LICENSE](LICENSE)
AGPL-3.0 License - see [LICENSE](LICENSE)
## Support

View File

@@ -1,24 +1,24 @@
# Claude Code 多智能体工作流系统
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL_v3-blue.svg)](https://www.gnu.org/licenses/agpl-3.0)
[![Claude Code](https://img.shields.io/badge/Claude-Code-blue)](https://claude.ai/code)
[![Version](https://img.shields.io/badge/Version-5.0-green)](https://github.com/cexll/myclaude)
[![Version](https://img.shields.io/badge/Version-5.2.2-green)](https://github.com/cexll/myclaude)
> AI 驱动的开发自动化 - Claude Code + Codex 协作
> AI 驱动的开发自动化 - 多后端执行架构 (Codex/Claude/Gemini)
## 核心概念:Claude Code + Codex
## 核心概念:多后端架构
本系统采用**双智能体架构**
本系统采用**双智能体架构**与可插拔 AI 后端
| 角色 | 智能体 | 职责 |
|------|-------|------|
| **编排者** | Claude Code | 规划、上下文收集、验证、用户交互 |
| **执行者** | Codex | 代码编辑、测试执行、文件操作 |
| **执行者** | codeagent-wrapper | 代码编辑、测试执行Codex/Claude/Gemini 后端)|
**为什么分离?**
- Claude Code 擅长理解上下文和编排复杂工作流
- Codex 擅长专注的代码生成和执行
- 两者结合效果优于单独使用
- 专业后端(Codex 擅长代码、Claude 擅长推理、Gemini 擅长原型)专注执行
- 通过 `--backend codex|claude|gemini` 匹配模型与任务
## 快速开始windows上请在Powershell中执行
@@ -201,7 +201,7 @@ python3 install.py --force
```bash
# 通过技能调用 Codex
codex-wrapper - <<'EOF'
codeagent-wrapper - <<'EOF'
在 @src/auth.ts 中实现 JWT 验证
EOF
```
@@ -209,7 +209,7 @@ EOF
### 并行执行
```bash
codex-wrapper --parallel <<'EOF'
codeagent-wrapper --parallel <<'EOF'
---TASK---
id: backend_api
workdir: /project/backend
@@ -237,7 +237,7 @@ bash install.sh
#### Windows 系统
Windows 系统会将 `codex-wrapper.exe` 安装到 `%USERPROFILE%\bin`
Windows 系统会将 `codeagent-wrapper.exe` 安装到 `%USERPROFILE%\bin`
```powershell
# PowerShell推荐
@@ -308,7 +308,7 @@ python3 install.py --module dev --force
## 许可证
MIT License - 查看 [LICENSE](LICENSE)
AGPL-3.0 License - 查看 [LICENSE](LICENSE)
## 支持

11
codeagent-wrapper/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
# Build artifacts
codeagent-wrapper
codeagent-wrapper.exe
*.test
# Coverage reports
coverage.out
coverage*.out
cover.out
cover_*.out
coverage.html

View File

@@ -0,0 +1,78 @@
package main
// Backend defines the contract for invoking different AI CLI backends.
// Each backend is responsible for supplying the executable command and
// building the argument list based on the wrapper config.
type Backend interface {
Name() string
BuildArgs(cfg *Config, targetArg string) []string
Command() string
}
type CodexBackend struct{}
func (CodexBackend) Name() string { return "codex" }
func (CodexBackend) Command() string {
return "codex"
}
func (CodexBackend) BuildArgs(cfg *Config, targetArg string) []string {
return buildCodexArgs(cfg, targetArg)
}
type ClaudeBackend struct{}
func (ClaudeBackend) Name() string { return "claude" }
func (ClaudeBackend) Command() string {
return "claude"
}
func (ClaudeBackend) BuildArgs(cfg *Config, targetArg string) []string {
if cfg == nil {
return nil
}
args := []string{"-p", "--dangerously-skip-permissions"}
// Only skip permissions when explicitly requested
// if cfg.SkipPermissions {
// 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 cfg.Mode == "resume" {
if cfg.SessionID != "" {
// Claude CLI uses -r <session_id> for resume.
args = append(args, "-r", cfg.SessionID)
}
}
// Note: claude CLI doesn't support -C flag; workdir set via cmd.Dir
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
return args
}
type GeminiBackend struct{}
func (GeminiBackend) Name() string { return "gemini" }
func (GeminiBackend) Command() string {
return "gemini"
}
func (GeminiBackend) BuildArgs(cfg *Config, targetArg string) []string {
if cfg == nil {
return nil
}
args := []string{"-o", "stream-json", "-y"}
if cfg.Mode == "resume" {
if cfg.SessionID != "" {
args = append(args, "-r", cfg.SessionID)
}
}
// Note: gemini CLI doesn't support -C flag; workdir set via cmd.Dir
args = append(args, "-p", targetArg)
return args
}

View File

@@ -0,0 +1,122 @@
package main
import (
"reflect"
"testing"
)
func TestClaudeBuildArgs_ModesAndPermissions(t *testing.T) {
backend := ClaudeBackend{}
t.Run("new mode uses workdir without skip by default", func(t *testing.T) {
cfg := &Config{Mode: "new", WorkDir: "/repo"}
got := backend.BuildArgs(cfg, "todo")
want := []string{"-p", "--dangerously-skip-permissions", "--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 opt-in skip permissions with default workdir", func(t *testing.T) {
cfg := &Config{Mode: "new", SkipPermissions: true}
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 uses session id and omits workdir", func(t *testing.T) {
cfg := &Config{Mode: "resume", SessionID: "sid-123", WorkDir: "/ignored"}
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("resume mode without session still returns base flags", func(t *testing.T) {
cfg := &Config{Mode: "resume", WorkDir: "/ignored"}
got := backend.BuildArgs(cfg, "follow-up")
want := []string{"-p", "--dangerously-skip-permissions", "--setting-sources", "", "--output-format", "stream-json", "--verbose", "follow-up"}
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 TestClaudeBuildArgs_GeminiAndCodexModes(t *testing.T) {
t.Run("gemini new mode defaults workdir", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &Config{Mode: "new", WorkDir: "/workspace"}
got := backend.BuildArgs(cfg, "task")
want := []string{"-o", "stream-json", "-y", "-p", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini resume mode uses session id", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &Config{Mode: "resume", SessionID: "sid-999"}
got := backend.BuildArgs(cfg, "resume")
want := []string{"-o", "stream-json", "-y", "-r", "sid-999", "-p", "resume"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
t.Run("gemini resume mode without session omits identifier", func(t *testing.T) {
backend := GeminiBackend{}
cfg := &Config{Mode: "resume"}
got := backend.BuildArgs(cfg, "resume")
want := []string{"-o", "stream-json", "-y", "-p", "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("codex build args passthrough remains intact", func(t *testing.T) {
backend := CodexBackend{}
cfg := &Config{Mode: "new", WorkDir: "/tmp"}
got := backend.BuildArgs(cfg, "task")
want := []string{"e", "--skip-git-repo-check", "-C", "/tmp", "--json", "task"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
})
}
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)
}
}
}

View File

@@ -2,11 +2,13 @@ package main
import (
"bufio"
"context"
"fmt"
"os"
"regexp"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -319,3 +321,106 @@ func TestLoggerOrderPreservation(t *testing.T) {
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)
}
}

272
codeagent-wrapper/config.go Normal file
View File

@@ -0,0 +1,272 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"strconv"
"strings"
)
// Config holds CLI configuration
type Config struct {
Mode string // "new" or "resume"
Task string
SessionID string
WorkDir string
ExplicitStdin bool
Timeout int
Backend string
SkipPermissions bool
MaxParallelWorkers int
}
// ParallelConfig defines the JSON schema for parallel execution
type ParallelConfig struct {
Tasks []TaskSpec `json:"tasks"`
GlobalBackend string `json:"backend,omitempty"`
}
// TaskSpec describes an individual task entry in the parallel config
type TaskSpec struct {
ID string `json:"id"`
Task string `json:"task"`
WorkDir string `json:"workdir,omitempty"`
Dependencies []string `json:"dependencies,omitempty"`
SessionID string `json:"session_id,omitempty"`
Backend string `json:"backend,omitempty"`
Mode string `json:"-"`
UseStdin bool `json:"-"`
Context context.Context `json:"-"`
}
// TaskResult captures the execution outcome of a task
type TaskResult struct {
TaskID string `json:"task_id"`
ExitCode int `json:"exit_code"`
Message string `json:"message"`
SessionID string `json:"session_id"`
Error string `json:"error"`
LogPath string `json:"log_path"`
}
var backendRegistry = map[string]Backend{
"codex": CodexBackend{},
"claude": ClaudeBackend{},
"gemini": GeminiBackend{},
}
func selectBackend(name string) (Backend, error) {
key := strings.ToLower(strings.TrimSpace(name))
if key == "" {
key = defaultBackendName
}
if backend, ok := backendRegistry[key]; ok {
return backend, nil
}
return nil, fmt.Errorf("unsupported backend %q", name)
}
func envFlagEnabled(key string) bool {
val, ok := os.LookupEnv(key)
if !ok {
return false
}
val = strings.TrimSpace(strings.ToLower(val))
switch val {
case "", "0", "false", "no", "off":
return false
default:
return true
}
}
func parseBoolFlag(val string, defaultValue bool) bool {
val = strings.TrimSpace(strings.ToLower(val))
switch val {
case "1", "true", "yes", "on":
return true
case "0", "false", "no", "off":
return false
default:
return defaultValue
}
}
func parseParallelConfig(data []byte) (*ParallelConfig, error) {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 {
return nil, fmt.Errorf("parallel config is empty")
}
tasks := strings.Split(string(trimmed), "---TASK---")
var cfg ParallelConfig
seen := make(map[string]struct{})
taskIndex := 0
for _, taskBlock := range tasks {
taskBlock = strings.TrimSpace(taskBlock)
if taskBlock == "" {
continue
}
taskIndex++
parts := strings.SplitN(taskBlock, "---CONTENT---", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("task block #%d missing ---CONTENT--- separator", taskIndex)
}
meta := strings.TrimSpace(parts[0])
content := strings.TrimSpace(parts[1])
task := TaskSpec{WorkDir: defaultWorkdir}
for _, line := range strings.Split(meta, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
kv := strings.SplitN(line, ":", 2)
if len(kv) != 2 {
continue
}
key := strings.TrimSpace(kv[0])
value := strings.TrimSpace(kv[1])
switch key {
case "id":
task.ID = value
case "workdir":
task.WorkDir = value
case "session_id":
task.SessionID = value
task.Mode = "resume"
case "backend":
task.Backend = value
case "dependencies":
for _, dep := range strings.Split(value, ",") {
dep = strings.TrimSpace(dep)
if dep != "" {
task.Dependencies = append(task.Dependencies, dep)
}
}
}
}
if task.Mode == "" {
task.Mode = "new"
}
if task.ID == "" {
return nil, fmt.Errorf("task block #%d missing id field", taskIndex)
}
if content == "" {
return nil, fmt.Errorf("task block #%d (%q) missing content", taskIndex, task.ID)
}
if _, exists := seen[task.ID]; exists {
return nil, fmt.Errorf("task block #%d has duplicate id: %s", taskIndex, task.ID)
}
task.Task = content
cfg.Tasks = append(cfg.Tasks, task)
seen[task.ID] = struct{}{}
}
if len(cfg.Tasks) == 0 {
return nil, fmt.Errorf("no tasks found")
}
return &cfg, nil
}
func parseArgs() (*Config, error) {
args := os.Args[1:]
if len(args) == 0 {
return nil, fmt.Errorf("task required")
}
backendName := defaultBackendName
skipPermissions := envFlagEnabled("CODEAGENT_SKIP_PERMISSIONS")
filtered := make([]string, 0, len(args))
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--backend":
if i+1 >= len(args) {
return nil, fmt.Errorf("--backend flag requires a value")
}
backendName = args[i+1]
i++
continue
case strings.HasPrefix(arg, "--backend="):
value := strings.TrimPrefix(arg, "--backend=")
if value == "" {
return nil, fmt.Errorf("--backend flag requires a value")
}
backendName = value
continue
case arg == "--skip-permissions", arg == "--dangerously-skip-permissions":
skipPermissions = true
continue
case strings.HasPrefix(arg, "--skip-permissions="):
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--skip-permissions="), skipPermissions)
continue
case strings.HasPrefix(arg, "--dangerously-skip-permissions="):
skipPermissions = parseBoolFlag(strings.TrimPrefix(arg, "--dangerously-skip-permissions="), skipPermissions)
continue
}
filtered = append(filtered, arg)
}
if len(filtered) == 0 {
return nil, fmt.Errorf("task required")
}
args = filtered
cfg := &Config{WorkDir: defaultWorkdir, Backend: backendName, SkipPermissions: skipPermissions}
cfg.MaxParallelWorkers = resolveMaxParallelWorkers()
if args[0] == "resume" {
if len(args) < 3 {
return nil, fmt.Errorf("resume mode requires: resume <session_id> <task>")
}
cfg.Mode = "resume"
cfg.SessionID = args[1]
cfg.Task = args[2]
cfg.ExplicitStdin = (args[2] == "-")
if len(args) > 3 {
cfg.WorkDir = args[3]
}
} else {
cfg.Mode = "new"
cfg.Task = args[0]
cfg.ExplicitStdin = (args[0] == "-")
if len(args) > 1 {
cfg.WorkDir = args[1]
}
}
return cfg, nil
}
const maxParallelWorkersLimit = 100
func resolveMaxParallelWorkers() int {
raw := strings.TrimSpace(os.Getenv("CODEAGENT_MAX_PARALLEL_WORKERS"))
if raw == "" {
return 0
}
value, err := strconv.Atoi(raw)
if err != nil || value < 0 {
logWarn(fmt.Sprintf("Invalid CODEAGENT_MAX_PARALLEL_WORKERS=%q, falling back to unlimited", raw))
return 0
}
if value > maxParallelWorkersLimit {
logWarn(fmt.Sprintf("CODEAGENT_MAX_PARALLEL_WORKERS=%d exceeds limit, capping at %d", value, maxParallelWorkersLimit))
return maxParallelWorkersLimit
}
return value
}

View File

@@ -0,0 +1,884 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"sort"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
)
// commandRunner abstracts exec.Cmd for testability
type commandRunner interface {
Start() error
Wait() error
StdoutPipe() (io.ReadCloser, error)
StdinPipe() (io.WriteCloser, error)
SetStderr(io.Writer)
SetDir(string)
Process() processHandle
}
// processHandle abstracts os.Process for testability
type processHandle interface {
Pid() int
Kill() error
Signal(os.Signal) error
}
// realCmd implements commandRunner using exec.Cmd
type realCmd struct {
cmd *exec.Cmd
}
func (r *realCmd) Start() error {
if r.cmd == nil {
return errors.New("command is nil")
}
return r.cmd.Start()
}
func (r *realCmd) Wait() error {
if r.cmd == nil {
return errors.New("command is nil")
}
return r.cmd.Wait()
}
func (r *realCmd) StdoutPipe() (io.ReadCloser, error) {
if r.cmd == nil {
return nil, errors.New("command is nil")
}
return r.cmd.StdoutPipe()
}
func (r *realCmd) StdinPipe() (io.WriteCloser, error) {
if r.cmd == nil {
return nil, errors.New("command is nil")
}
return r.cmd.StdinPipe()
}
func (r *realCmd) SetStderr(w io.Writer) {
if r.cmd != nil {
r.cmd.Stderr = w
}
}
func (r *realCmd) SetDir(dir string) {
if r.cmd != nil {
r.cmd.Dir = dir
}
}
func (r *realCmd) Process() processHandle {
if r == nil || r.cmd == nil || r.cmd.Process == nil {
return nil
}
return &realProcess{proc: r.cmd.Process}
}
// realProcess implements processHandle using os.Process
type realProcess struct {
proc *os.Process
}
func (p *realProcess) Pid() int {
if p == nil || p.proc == nil {
return 0
}
return p.proc.Pid
}
func (p *realProcess) Kill() error {
if p == nil || p.proc == nil {
return nil
}
return p.proc.Kill()
}
func (p *realProcess) Signal(sig os.Signal) error {
if p == nil || p.proc == nil {
return nil
}
return p.proc.Signal(sig)
}
// newCommandRunner creates a new commandRunner (test hook injection point)
var newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &realCmd{cmd: commandContext(ctx, name, args...)}
}
type parseResult struct {
message string
threadID string
}
var runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
if task.WorkDir == "" {
task.WorkDir = defaultWorkdir
}
if task.Mode == "" {
task.Mode = "new"
}
if task.UseStdin || shouldUseStdin(task.Task, false) {
task.UseStdin = true
}
backendName := task.Backend
if backendName == "" {
backendName = defaultBackendName
}
backend, err := selectBackendFn(backendName)
if err != nil {
return TaskResult{TaskID: task.ID, ExitCode: 1, Error: err.Error()}
}
task.Backend = backend.Name()
parentCtx := task.Context
if parentCtx == nil {
parentCtx = context.Background()
}
return runCodexTaskWithContext(parentCtx, task, backend, nil, false, true, timeout)
}
func topologicalSort(tasks []TaskSpec) ([][]TaskSpec, error) {
idToTask := make(map[string]TaskSpec, len(tasks))
indegree := make(map[string]int, len(tasks))
adj := make(map[string][]string, len(tasks))
for _, task := range tasks {
idToTask[task.ID] = task
indegree[task.ID] = 0
}
for _, task := range tasks {
for _, dep := range task.Dependencies {
if _, ok := idToTask[dep]; !ok {
return nil, fmt.Errorf("dependency %q not found for task %q", dep, task.ID)
}
indegree[task.ID]++
adj[dep] = append(adj[dep], task.ID)
}
}
queue := make([]string, 0, len(tasks))
for _, task := range tasks {
if indegree[task.ID] == 0 {
queue = append(queue, task.ID)
}
}
layers := make([][]TaskSpec, 0)
processed := 0
for len(queue) > 0 {
current := queue
queue = nil
layer := make([]TaskSpec, len(current))
for i, id := range current {
layer[i] = idToTask[id]
processed++
}
layers = append(layers, layer)
next := make([]string, 0)
for _, id := range current {
for _, neighbor := range adj[id] {
indegree[neighbor]--
if indegree[neighbor] == 0 {
next = append(next, neighbor)
}
}
}
queue = append(queue, next...)
}
if processed != len(tasks) {
cycleIDs := make([]string, 0)
for id, deg := range indegree {
if deg > 0 {
cycleIDs = append(cycleIDs, id)
}
}
sort.Strings(cycleIDs)
return nil, fmt.Errorf("cycle detected involving tasks: %s", strings.Join(cycleIDs, ","))
}
return layers, nil
}
func executeConcurrent(layers [][]TaskSpec, timeout int) []TaskResult {
maxWorkers := resolveMaxParallelWorkers()
return executeConcurrentWithContext(context.Background(), layers, timeout, maxWorkers)
}
func executeConcurrentWithContext(parentCtx context.Context, layers [][]TaskSpec, timeout int, maxWorkers int) []TaskResult {
totalTasks := 0
for _, layer := range layers {
totalTasks += len(layer)
}
results := make([]TaskResult, 0, totalTasks)
failed := make(map[string]TaskResult, totalTasks)
resultsCh := make(chan TaskResult, totalTasks)
var startPrintMu sync.Mutex
bannerPrinted := false
printTaskStart := func(taskID string) {
logger := activeLogger()
if logger == nil {
return
}
path := logger.Path()
if path == "" {
return
}
startPrintMu.Lock()
if !bannerPrinted {
fmt.Fprintln(os.Stderr, "=== Starting Parallel Execution ===")
bannerPrinted = true
}
fmt.Fprintf(os.Stderr, "Task %s: Log: %s\n", taskID, path)
startPrintMu.Unlock()
}
ctx := parentCtx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
defer cancel()
workerLimit := maxWorkers
if workerLimit < 0 {
workerLimit = 0
}
var sem chan struct{}
if workerLimit > 0 {
sem = make(chan struct{}, workerLimit)
}
logConcurrencyPlanning(workerLimit, totalTasks)
acquireSlot := func() bool {
if sem == nil {
return true
}
select {
case sem <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
releaseSlot := func() {
if sem == nil {
return
}
select {
case <-sem:
default:
}
}
var activeWorkers int64
for _, layer := range layers {
var wg sync.WaitGroup
executed := 0
for _, task := range layer {
if skip, reason := shouldSkipTask(task, failed); skip {
res := TaskResult{TaskID: task.ID, ExitCode: 1, Error: reason}
results = append(results, res)
failed[task.ID] = res
continue
}
if ctx.Err() != nil {
res := cancelledTaskResult(task.ID, ctx)
results = append(results, res)
failed[task.ID] = res
continue
}
executed++
wg.Add(1)
go func(ts TaskSpec) {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
resultsCh <- TaskResult{TaskID: ts.ID, ExitCode: 1, Error: fmt.Sprintf("panic: %v", r)}
}
}()
if !acquireSlot() {
resultsCh <- cancelledTaskResult(ts.ID, ctx)
return
}
defer releaseSlot()
current := atomic.AddInt64(&activeWorkers, 1)
logConcurrencyState("start", ts.ID, int(current), workerLimit)
defer func() {
after := atomic.AddInt64(&activeWorkers, -1)
logConcurrencyState("done", ts.ID, int(after), workerLimit)
}()
ts.Context = ctx
printTaskStart(ts.ID)
resultsCh <- runCodexTaskFn(ts, timeout)
}(task)
}
wg.Wait()
for i := 0; i < executed; i++ {
res := <-resultsCh
results = append(results, res)
if res.ExitCode != 0 || res.Error != "" {
failed[res.TaskID] = res
}
}
}
return results
}
func cancelledTaskResult(taskID string, ctx context.Context) TaskResult {
exitCode := 130
msg := "execution cancelled"
if ctx != nil && errors.Is(ctx.Err(), context.DeadlineExceeded) {
exitCode = 124
msg = "execution timeout"
}
return TaskResult{TaskID: taskID, ExitCode: exitCode, Error: msg}
}
func shouldSkipTask(task TaskSpec, failed map[string]TaskResult) (bool, string) {
if len(task.Dependencies) == 0 {
return false, ""
}
var blocked []string
for _, dep := range task.Dependencies {
if _, ok := failed[dep]; ok {
blocked = append(blocked, dep)
}
}
if len(blocked) == 0 {
return false, ""
}
return true, fmt.Sprintf("skipped due to failed dependencies: %s", strings.Join(blocked, ","))
}
func generateFinalOutput(results []TaskResult) string {
var sb strings.Builder
success := 0
failed := 0
for _, res := range results {
if res.ExitCode == 0 && res.Error == "" {
success++
} else {
failed++
}
}
sb.WriteString(fmt.Sprintf("=== Parallel Execution Summary ===\n"))
sb.WriteString(fmt.Sprintf("Total: %d | Success: %d | Failed: %d\n\n", len(results), success, failed))
for _, res := range results {
sb.WriteString(fmt.Sprintf("--- Task: %s ---\n", res.TaskID))
if res.Error != "" {
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\nError: %s\n", res.ExitCode, res.Error))
} else if res.ExitCode != 0 {
sb.WriteString(fmt.Sprintf("Status: FAILED (exit code %d)\n", res.ExitCode))
} else {
sb.WriteString("Status: SUCCESS\n")
}
if res.SessionID != "" {
sb.WriteString(fmt.Sprintf("Session: %s\n", res.SessionID))
}
if res.LogPath != "" {
sb.WriteString(fmt.Sprintf("Log: %s\n", res.LogPath))
}
if res.Message != "" {
sb.WriteString(fmt.Sprintf("\n%s\n", res.Message))
}
sb.WriteString("\n")
}
return sb.String()
}
func buildCodexArgs(cfg *Config, targetArg string) []string {
if cfg.Mode == "resume" {
return []string{
"e",
"--skip-git-repo-check",
"--json",
"resume",
cfg.SessionID,
targetArg,
}
}
return []string{
"e",
"--skip-git-repo-check",
"-C", cfg.WorkDir,
"--json",
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 {
result := TaskResult{TaskID: taskSpec.ID}
setLogPath := func() {
if result.LogPath != "" {
return
}
if logger := activeLogger(); logger != nil {
result.LogPath = logger.Path()
}
}
cfg := &Config{
Mode: taskSpec.Mode,
Task: taskSpec.Task,
SessionID: taskSpec.SessionID,
WorkDir: taskSpec.WorkDir,
Backend: defaultBackendName,
}
commandName := codexCommand
argsBuilder := buildCodexArgsFn
if backend != nil {
commandName = backend.Command()
argsBuilder = backend.BuildArgs
cfg.Backend = backend.Name()
} else if taskSpec.Backend != "" {
cfg.Backend = taskSpec.Backend
} else if commandName != "" {
cfg.Backend = commandName
}
if cfg.Mode == "" {
cfg.Mode = "new"
}
if cfg.WorkDir == "" {
cfg.WorkDir = defaultWorkdir
}
useStdin := taskSpec.UseStdin
targetArg := taskSpec.Task
if useStdin {
targetArg = "-"
}
var codexArgs []string
if useCustomArgs {
codexArgs = customArgs
} else {
codexArgs = argsBuilder(cfg, targetArg)
}
prefixMsg := func(msg string) string {
if taskSpec.ID == "" {
return msg
}
return fmt.Sprintf("[Task: %s] %s", taskSpec.ID, msg)
}
var logInfoFn func(string)
var logWarnFn func(string)
var logErrorFn func(string)
if silent {
// Silent mode: only persist to file when available; avoid stderr noise.
logInfoFn = func(msg string) {
if logger := activeLogger(); logger != nil {
logger.Info(prefixMsg(msg))
}
}
logWarnFn = func(msg string) {
if logger := activeLogger(); logger != nil {
logger.Warn(prefixMsg(msg))
}
}
logErrorFn = func(msg string) {
if logger := activeLogger(); logger != nil {
logger.Error(prefixMsg(msg))
}
}
} else {
logInfoFn = func(msg string) { logInfo(prefixMsg(msg)) }
logWarnFn = func(msg string) { logWarn(prefixMsg(msg)) }
logErrorFn = func(msg string) { logError(prefixMsg(msg)) }
}
stderrBuf := &tailBuffer{limit: stderrCaptureLimit}
var stdoutLogger *logWriter
var stderrLogger *logWriter
var tempLogger *Logger
if silent && activeLogger() == nil {
if l, err := NewLogger(); err == nil {
setLogger(l)
tempLogger = l
}
}
defer func() {
if tempLogger != nil {
_ = closeLogger()
}
}()
defer setLogPath()
if logger := activeLogger(); logger != nil {
result.LogPath = logger.Path()
}
if !silent {
stdoutLogger = newLogWriter("CODEX_STDOUT: ", codexLogLineLimit)
stderrLogger = newLogWriter("CODEX_STDERR: ", codexLogLineLimit)
}
ctx := parentCtx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSec)*time.Second)
defer cancel()
ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
defer stop()
attachStderr := func(msg string) string {
return fmt.Sprintf("%s; stderr: %s", msg, stderrBuf.String())
}
cmd := newCommandRunner(ctx, commandName, codexArgs...)
// For backends that don't support -C flag (claude, gemini), set working directory via cmd.Dir
// Codex passes workdir via -C flag, so we skip setting Dir for it to avoid conflicts
if cfg.Mode != "resume" && commandName != "codex" && cfg.WorkDir != "" {
cmd.SetDir(cfg.WorkDir)
}
stderrWriters := []io.Writer{stderrBuf}
if stderrLogger != nil {
stderrWriters = append(stderrWriters, stderrLogger)
}
if !silent {
stderrWriters = append([]io.Writer{os.Stderr}, stderrWriters...)
}
if len(stderrWriters) == 1 {
cmd.SetStderr(stderrWriters[0])
} else {
cmd.SetStderr(io.MultiWriter(stderrWriters...))
}
var stdinPipe io.WriteCloser
var err error
if useStdin {
stdinPipe, err = cmd.StdinPipe()
if err != nil {
logErrorFn("Failed to create stdin pipe: " + err.Error())
result.ExitCode = 1
result.Error = attachStderr("failed to create stdin pipe: " + err.Error())
return result
}
}
stdout, err := cmd.StdoutPipe()
if err != nil {
logErrorFn("Failed to create stdout pipe: " + err.Error())
result.ExitCode = 1
result.Error = attachStderr("failed to create stdout pipe: " + err.Error())
return result
}
stdoutReader := io.Reader(stdout)
if stdoutLogger != nil {
stdoutReader = io.TeeReader(stdout, stdoutLogger)
}
// Start parse goroutine BEFORE starting the command to avoid race condition
// where fast-completing commands close stdout before parser starts reading
messageSeen := make(chan struct{}, 1)
parseCh := make(chan parseResult, 1)
go func() {
msg, tid := parseJSONStreamInternal(stdoutReader, logWarnFn, logInfoFn, func() {
select {
case messageSeen <- struct{}{}:
default:
}
})
parseCh <- parseResult{message: msg, threadID: tid}
}()
logInfoFn(fmt.Sprintf("Starting %s with args: %s %s...", commandName, commandName, strings.Join(codexArgs[:min(5, len(codexArgs))], " ")))
if err := cmd.Start(); err != nil {
if strings.Contains(err.Error(), "executable file not found") {
msg := fmt.Sprintf("%s command not found in PATH", commandName)
logErrorFn(msg)
result.ExitCode = 127
result.Error = attachStderr(msg)
return result
}
logErrorFn("Failed to start " + commandName + ": " + err.Error())
result.ExitCode = 1
result.Error = attachStderr("failed to start " + commandName + ": " + err.Error())
return result
}
logInfoFn(fmt.Sprintf("Starting %s with PID: %d", commandName, cmd.Process().Pid()))
if logger := activeLogger(); logger != nil {
logInfoFn(fmt.Sprintf("Log capturing to: %s", logger.Path()))
}
if useStdin && stdinPipe != nil {
logInfoFn(fmt.Sprintf("Writing %d chars to stdin...", len(taskSpec.Task)))
go func(data string) {
defer stdinPipe.Close()
_, _ = io.WriteString(stdinPipe, data)
}(taskSpec.Task)
logInfoFn("Stdin closed")
}
waitCh := make(chan error, 1)
go func() { waitCh <- cmd.Wait() }()
var waitErr error
var forceKillTimer *forceKillTimer
var ctxCancelled bool
select {
case waitErr = <-waitCh:
case <-ctx.Done():
ctxCancelled = true
logErrorFn(cancelReason(commandName, ctx))
forceKillTimer = terminateCommandFn(cmd)
waitErr = <-waitCh
}
if forceKillTimer != nil {
forceKillTimer.Stop()
}
var parsed parseResult
if ctxCancelled {
closeWithReason(stdout, stdoutCloseReasonCtx)
parsed = <-parseCh
} else {
drainTimer := time.NewTimer(stdoutDrainTimeout)
defer drainTimer.Stop()
select {
case parsed = <-parseCh:
closeWithReason(stdout, stdoutCloseReasonWait)
case <-messageSeen:
closeWithReason(stdout, stdoutCloseReasonWait)
parsed = <-parseCh
case <-drainTimer.C:
closeWithReason(stdout, stdoutCloseReasonDrain)
parsed = <-parseCh
}
}
if ctxErr := ctx.Err(); ctxErr != nil {
if errors.Is(ctxErr, context.DeadlineExceeded) {
result.ExitCode = 124
result.Error = attachStderr(fmt.Sprintf("%s execution timeout", commandName))
return result
}
result.ExitCode = 130
result.Error = attachStderr("execution cancelled")
return result
}
if waitErr != nil {
if exitErr, ok := waitErr.(*exec.ExitError); ok {
code := exitErr.ExitCode()
logErrorFn(fmt.Sprintf("%s exited with status %d", commandName, code))
result.ExitCode = code
result.Error = attachStderr(fmt.Sprintf("%s exited with status %d", commandName, code))
return result
}
logErrorFn(commandName + " error: " + waitErr.Error())
result.ExitCode = 1
result.Error = attachStderr(commandName + " error: " + waitErr.Error())
return result
}
message := parsed.message
threadID := parsed.threadID
if message == "" {
logErrorFn(fmt.Sprintf("%s completed without agent_message output", commandName))
result.ExitCode = 1
result.Error = attachStderr(fmt.Sprintf("%s completed without agent_message output", commandName))
return result
}
if stdoutLogger != nil {
stdoutLogger.Flush()
}
if stderrLogger != nil {
stderrLogger.Flush()
}
result.ExitCode = 0
result.Message = message
result.SessionID = threadID
if logger := activeLogger(); logger != nil {
result.LogPath = logger.Path()
}
return result
}
func forwardSignals(ctx context.Context, cmd commandRunner, logErrorFn func(string)) {
notify := signalNotifyFn
stop := signalStopFn
if notify == nil {
notify = signal.Notify
}
if stop == nil {
stop = signal.Stop
}
sigCh := make(chan os.Signal, 1)
notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
defer stop(sigCh)
select {
case sig := <-sigCh:
logErrorFn(fmt.Sprintf("Received signal: %v", sig))
if proc := cmd.Process(); proc != nil {
_ = proc.Signal(syscall.SIGTERM)
time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
}
})
}
case <-ctx.Done():
}
}()
}
func cancelReason(commandName string, ctx context.Context) string {
if ctx == nil {
return "Context cancelled"
}
if commandName == "" {
commandName = codexCommand
}
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
return fmt.Sprintf("%s execution timeout", commandName)
}
return fmt.Sprintf("Execution cancelled, terminating %s process", commandName)
}
type stdoutReasonCloser interface {
CloseWithReason(string) error
}
func closeWithReason(rc io.ReadCloser, reason string) {
if rc == nil {
return
}
if c, ok := rc.(stdoutReasonCloser); ok {
_ = c.CloseWithReason(reason)
return
}
_ = rc.Close()
}
type forceKillTimer struct {
timer *time.Timer
done chan struct{}
stopped atomic.Bool
drained atomic.Bool
}
func (t *forceKillTimer) Stop() {
if t == nil || t.timer == nil {
return
}
if !t.timer.Stop() {
<-t.done
t.drained.Store(true)
}
t.stopped.Store(true)
}
func terminateCommand(cmd commandRunner) *forceKillTimer {
if cmd == nil {
return nil
}
proc := cmd.Process()
if proc == nil {
return nil
}
_ = proc.Signal(syscall.SIGTERM)
done := make(chan struct{}, 1)
timer := time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
}
close(done)
})
return &forceKillTimer{timer: timer, done: done}
}
func terminateProcess(cmd commandRunner) *time.Timer {
if cmd == nil {
return nil
}
proc := cmd.Process()
if proc == nil {
return nil
}
_ = proc.Signal(syscall.SIGTERM)
return time.AfterFunc(time.Duration(forceKillDelay.Load())*time.Second, func() {
if p := cmd.Process(); p != nil {
_ = p.Kill()
}
})
}

View File

@@ -0,0 +1,577 @@
package main
import (
"bytes"
"context"
"errors"
"io"
"os"
"os/exec"
"strings"
"sync"
"sync/atomic"
"syscall"
"testing"
"time"
)
type execFakeProcess struct {
pid int
signals []os.Signal
killed atomic.Int32
mu sync.Mutex
}
func (p *execFakeProcess) Pid() int { return p.pid }
func (p *execFakeProcess) Kill() error {
p.killed.Add(1)
return nil
}
func (p *execFakeProcess) Signal(sig os.Signal) error {
p.mu.Lock()
p.signals = append(p.signals, sig)
p.mu.Unlock()
return nil
}
type writeCloserStub struct {
bytes.Buffer
closed atomic.Bool
}
func (w *writeCloserStub) Close() error {
w.closed.Store(true)
return nil
}
type reasonReadCloser struct {
r io.Reader
closed []string
mu sync.Mutex
closedC chan struct{}
}
func newReasonReadCloser(data string) *reasonReadCloser {
return &reasonReadCloser{r: strings.NewReader(data), closedC: make(chan struct{}, 1)}
}
func (rc *reasonReadCloser) Read(p []byte) (int, error) { return rc.r.Read(p) }
func (rc *reasonReadCloser) Close() error { rc.record("close"); return nil }
func (rc *reasonReadCloser) CloseWithReason(reason string) error {
rc.record(reason)
return nil
}
func (rc *reasonReadCloser) record(reason string) {
rc.mu.Lock()
rc.closed = append(rc.closed, reason)
rc.mu.Unlock()
select {
case rc.closedC <- struct{}{}:
default:
}
}
type execFakeRunner struct {
stdout io.ReadCloser
process processHandle
stdin io.WriteCloser
waitErr error
waitDelay time.Duration
startErr error
stdoutErr error
stdinErr error
allowNilProcess bool
started atomic.Bool
}
func (f *execFakeRunner) Start() error {
if f.startErr != nil {
return f.startErr
}
f.started.Store(true)
return nil
}
func (f *execFakeRunner) Wait() error {
if f.waitDelay > 0 {
time.Sleep(f.waitDelay)
}
return f.waitErr
}
func (f *execFakeRunner) StdoutPipe() (io.ReadCloser, error) {
if f.stdoutErr != nil {
return nil, f.stdoutErr
}
if f.stdout == nil {
f.stdout = io.NopCloser(strings.NewReader(""))
}
return f.stdout, nil
}
func (f *execFakeRunner) StdinPipe() (io.WriteCloser, error) {
if f.stdinErr != nil {
return nil, f.stdinErr
}
if f.stdin != nil {
return f.stdin, nil
}
return &writeCloserStub{}, nil
}
func (f *execFakeRunner) SetStderr(io.Writer) {}
func (f *execFakeRunner) SetDir(string) {}
func (f *execFakeRunner) Process() processHandle {
if f.process != nil {
return f.process
}
if f.allowNilProcess {
return nil
}
return &execFakeProcess{pid: 1}
}
func TestExecutorHelperCoverage(t *testing.T) {
t.Run("realCmdAndProcess", func(t *testing.T) {
rc := &realCmd{}
if err := rc.Start(); err == nil {
t.Fatalf("expected error for nil command")
}
if err := rc.Wait(); err == nil {
t.Fatalf("expected error for nil command")
}
if _, err := rc.StdoutPipe(); err == nil {
t.Fatalf("expected error for nil command")
}
if _, err := rc.StdinPipe(); err == nil {
t.Fatalf("expected error for nil command")
}
rc.SetStderr(io.Discard)
if rc.Process() != nil {
t.Fatalf("expected nil process")
}
rcWithCmd := &realCmd{cmd: &exec.Cmd{}}
rcWithCmd.SetStderr(io.Discard)
echoCmd := exec.Command("echo", "ok")
rcProc := &realCmd{cmd: echoCmd}
stdoutPipe, err := rcProc.StdoutPipe()
if err != nil {
t.Fatalf("StdoutPipe error: %v", err)
}
stdinPipe, err := rcProc.StdinPipe()
if err != nil {
t.Fatalf("StdinPipe error: %v", err)
}
rcProc.SetStderr(io.Discard)
if err := rcProc.Start(); err != nil {
t.Fatalf("Start failed: %v", err)
}
_, _ = stdinPipe.Write([]byte{})
_ = stdinPipe.Close()
procHandle := rcProc.Process()
if procHandle == nil {
t.Fatalf("expected process handle")
}
_ = procHandle.Signal(syscall.SIGTERM)
_ = procHandle.Kill()
_ = rcProc.Wait()
_, _ = io.ReadAll(stdoutPipe)
rp := &realProcess{}
if rp.Pid() != 0 {
t.Fatalf("nil process should have pid 0")
}
if rp.Kill() != nil {
t.Fatalf("nil process Kill should be nil")
}
if rp.Signal(syscall.SIGTERM) != nil {
t.Fatalf("nil process Signal should be nil")
}
rpLive := &realProcess{proc: &os.Process{Pid: 99}}
if rpLive.Pid() != 99 {
t.Fatalf("expected pid 99, got %d", rpLive.Pid())
}
_ = rpLive.Kill()
_ = rpLive.Signal(syscall.SIGTERM)
})
t.Run("topologicalSortAndSkip", func(t *testing.T) {
layers, err := topologicalSort([]TaskSpec{{ID: "root"}, {ID: "child", Dependencies: []string{"root"}}})
if err != nil || len(layers) != 2 {
t.Fatalf("unexpected topological sort result: layers=%d err=%v", len(layers), err)
}
if _, err := topologicalSort([]TaskSpec{{ID: "cycle", Dependencies: []string{"cycle"}}}); err == nil {
t.Fatalf("expected cycle detection error")
}
failed := map[string]TaskResult{"root": {ExitCode: 1}}
if skip, _ := shouldSkipTask(TaskSpec{ID: "child", Dependencies: []string{"root"}}, failed); !skip {
t.Fatalf("should skip when dependency failed")
}
if skip, _ := shouldSkipTask(TaskSpec{ID: "leaf"}, failed); skip {
t.Fatalf("should not skip task without dependencies")
}
if skip, _ := shouldSkipTask(TaskSpec{ID: "child-ok", Dependencies: []string{"root"}}, map[string]TaskResult{}); skip {
t.Fatalf("should not skip when dependencies succeeded")
}
})
t.Run("cancelledTaskResult", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := cancelledTaskResult("t1", ctx)
if res.ExitCode != 130 {
t.Fatalf("expected cancel exit code, got %d", res.ExitCode)
}
timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 0)
defer timeoutCancel()
res = cancelledTaskResult("t2", timeoutCtx)
if res.ExitCode != 124 {
t.Fatalf("expected timeout exit code, got %d", res.ExitCode)
}
})
t.Run("generateFinalOutputAndArgs", func(t *testing.T) {
out := generateFinalOutput([]TaskResult{
{TaskID: "ok", ExitCode: 0},
{TaskID: "fail", ExitCode: 1, Error: "boom"},
})
if !strings.Contains(out, "ok") || !strings.Contains(out, "fail") {
t.Fatalf("unexpected summary output: %s", out)
}
out = generateFinalOutput([]TaskResult{{TaskID: "rich", ExitCode: 0, SessionID: "sess", LogPath: "/tmp/log", Message: "hello"}})
if !strings.Contains(out, "Session: sess") || !strings.Contains(out, "Log: /tmp/log") || !strings.Contains(out, "hello") {
t.Fatalf("rich output missing fields: %s", out)
}
args := buildCodexArgs(&Config{Mode: "new", WorkDir: "/tmp"}, "task")
if len(args) == 0 || args[3] != "/tmp" {
t.Fatalf("unexpected codex args: %+v", args)
}
args = buildCodexArgs(&Config{Mode: "resume", SessionID: "sess"}, "target")
if args[3] != "resume" || args[4] != "sess" {
t.Fatalf("unexpected resume args: %+v", args)
}
})
t.Run("executeConcurrentWrapper", func(t *testing.T) {
orig := runCodexTaskFn
defer func() { runCodexTaskFn = orig }()
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "done"}
}
os.Setenv("CODEAGENT_MAX_PARALLEL_WORKERS", "1")
defer os.Unsetenv("CODEAGENT_MAX_PARALLEL_WORKERS")
results := executeConcurrent([][]TaskSpec{{{ID: "wrap"}}}, 1)
if len(results) != 1 || results[0].TaskID != "wrap" {
t.Fatalf("unexpected wrapper results: %+v", results)
}
unbounded := executeConcurrentWithContext(context.Background(), [][]TaskSpec{{{ID: "unbounded"}}}, 1, 0)
if len(unbounded) != 1 || unbounded[0].ExitCode != 0 {
t.Fatalf("unexpected unbounded result: %+v", unbounded)
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
cancelled := executeConcurrentWithContext(ctx, [][]TaskSpec{{{ID: "cancel"}}}, 1, 1)
if cancelled[0].ExitCode == 0 {
t.Fatalf("expected cancelled result, got %+v", cancelled[0])
}
})
}
func TestExecutorRunCodexTaskWithContext(t *testing.T) {
origRunner := newCommandRunner
defer func() { newCommandRunner = origRunner }()
t.Run("success", func(t *testing.T) {
var firstStdout *reasonReadCloser
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
rc := newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"hello"}}`)
if firstStdout == nil {
firstStdout = rc
}
return &execFakeRunner{stdout: rc, process: &execFakeProcess{pid: 1234}}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{ID: "task-1", Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.Error != "" || res.Message != "hello" || res.ExitCode != 0 {
t.Fatalf("unexpected result: %+v", res)
}
select {
case <-firstStdout.closedC:
case <-time.After(1 * time.Second):
t.Fatalf("stdout not closed with reason")
}
orig := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "ok"}
}
t.Cleanup(func() { runCodexTaskFn = orig })
if res := runCodexTask(TaskSpec{Task: "task-text", WorkDir: "."}, true, 1); res.ExitCode != 0 {
t.Fatalf("runCodexTask failed: %+v", res)
}
msg, threadID, code := runCodexProcess(context.Background(), []string{"arg"}, "content", false, 1)
if code != 0 || msg == "" {
t.Fatalf("runCodexProcess unexpected result: msg=%q code=%d threadID=%s", msg, code, threadID)
}
})
t.Run("startErrors", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{startErr: errors.New("executable file not found"), process: &execFakeProcess{pid: 1}}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode != 127 {
t.Fatalf("expected missing executable exit code, got %d", res.ExitCode)
}
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{startErr: errors.New("start failed"), process: &execFakeProcess{pid: 2}}
}
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on start failure")
}
})
t.Run("timeoutAndPipes", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"slow"}}`),
process: &execFakeProcess{pid: 5},
waitDelay: 20 * time.Millisecond,
}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 0)
if res.ExitCode == 0 {
t.Fatalf("expected timeout result, got %+v", res)
}
})
t.Run("pipeErrors", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{stdoutErr: errors.New("stdout fail"), process: &execFakeProcess{pid: 6}}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure on stdout pipe error")
}
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{stdinErr: errors.New("stdin fail"), process: &execFakeProcess{pid: 7}}
}
res = runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: ".", UseStdin: true}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure on stdin pipe error")
}
})
t.Run("waitExitError", func(t *testing.T) {
err := exec.Command("false").Run()
exitErr, _ := err.(*exec.ExitError)
if exitErr == nil {
t.Fatalf("expected exec.ExitError")
}
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"ignored"}}`),
process: &execFakeProcess{pid: 8},
waitErr: exitErr,
}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected non-zero exit on wait error")
}
})
t.Run("contextCancelled", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"cancel"}}`),
process: &execFakeProcess{pid: 9},
waitDelay: 10 * time.Millisecond,
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
res := runCodexTaskWithContext(ctx, TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected cancellation result")
}
})
t.Run("silentLogger", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`),
process: &execFakeProcess{pid: 10},
}
}
_ = closeLogger()
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, true, 1)
if res.ExitCode != 0 || res.LogPath == "" {
t.Fatalf("expected success with temp logger, got %+v", res)
}
_ = closeLogger()
})
t.Run("missingMessage", func(t *testing.T) {
newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
return &execFakeRunner{
stdout: newReasonReadCloser(`{"type":"item.completed","item":{"type":"task","text":"noop"}}`),
process: &execFakeProcess{pid: 11},
}
}
res := runCodexTaskWithContext(context.Background(), TaskSpec{Task: "payload", WorkDir: "."}, nil, nil, false, false, 1)
if res.ExitCode == 0 {
t.Fatalf("expected failure when no agent_message returned")
}
})
}
func TestExecutorSignalAndTermination(t *testing.T) {
forceKillDelay.Store(0)
defer forceKillDelay.Store(5)
proc := &execFakeProcess{pid: 42}
cmd := &execFakeRunner{process: proc}
origNotify := signalNotifyFn
origStop := signalStopFn
defer func() {
signalNotifyFn = origNotify
signalStopFn = origStop
}()
signalNotifyFn = func(c chan<- os.Signal, sigs ...os.Signal) {
go func() { c <- syscall.SIGINT }()
}
signalStopFn = func(c chan<- os.Signal) {}
forwardSignals(context.Background(), cmd, func(string) {})
time.Sleep(20 * time.Millisecond)
proc.mu.Lock()
signalled := len(proc.signals)
proc.mu.Unlock()
if signalled == 0 {
t.Fatalf("process did not receive signal")
}
if proc.killed.Load() == 0 {
t.Fatalf("process was not killed after signal")
}
timer := terminateProcess(cmd)
if timer == nil {
t.Fatalf("terminateProcess returned nil timer")
}
timer.Stop()
ft := terminateCommand(cmd)
if ft == nil {
t.Fatalf("terminateCommand returned nil")
}
ft.Stop()
cmdKill := &execFakeRunner{process: &execFakeProcess{pid: 50}}
ftKill := terminateCommand(cmdKill)
time.Sleep(10 * time.Millisecond)
if p, ok := cmdKill.process.(*execFakeProcess); ok && p.killed.Load() == 0 {
t.Fatalf("terminateCommand did not kill process")
}
ftKill.Stop()
cmdKill2 := &execFakeRunner{process: &execFakeProcess{pid: 51}}
timer2 := terminateProcess(cmdKill2)
time.Sleep(10 * time.Millisecond)
if p, ok := cmdKill2.process.(*execFakeProcess); ok && p.killed.Load() == 0 {
t.Fatalf("terminateProcess did not kill process")
}
timer2.Stop()
if terminateCommand(nil) != nil {
t.Fatalf("terminateCommand should return nil for nil cmd")
}
if terminateCommand(&execFakeRunner{allowNilProcess: true}) != nil {
t.Fatalf("terminateCommand should return nil when process is nil")
}
if terminateProcess(nil) != nil {
t.Fatalf("terminateProcess should return nil for nil cmd")
}
if terminateProcess(&execFakeRunner{allowNilProcess: true}) != nil {
t.Fatalf("terminateProcess should return nil when process is nil")
}
signalNotifyFn = func(c chan<- os.Signal, sigs ...os.Signal) {}
ctxDone, cancelDone := context.WithCancel(context.Background())
cancelDone()
forwardSignals(ctxDone, &execFakeRunner{process: &execFakeProcess{pid: 70}}, func(string) {})
}
func TestExecutorCancelReasonAndCloseWithReason(t *testing.T) {
if reason := cancelReason("", nil); !strings.Contains(reason, "Context") {
t.Fatalf("unexpected cancelReason for nil ctx: %s", reason)
}
ctx, cancel := context.WithTimeout(context.Background(), 0)
defer cancel()
if !strings.Contains(cancelReason("cmd", ctx), "timeout") {
t.Fatalf("expected timeout reason")
}
cancelCtx, cancelFn := context.WithCancel(context.Background())
cancelFn()
if !strings.Contains(cancelReason("cmd", cancelCtx), "Execution cancelled") {
t.Fatalf("expected cancellation reason")
}
if !strings.Contains(cancelReason("", cancelCtx), "codex") {
t.Fatalf("expected default command name in cancel reason")
}
rc := &reasonReadCloser{r: strings.NewReader("data"), closedC: make(chan struct{}, 1)}
closeWithReason(rc, "why")
select {
case <-rc.closedC:
default:
t.Fatalf("CloseWithReason was not called")
}
plain := io.NopCloser(strings.NewReader("x"))
closeWithReason(plain, "noop")
closeWithReason(nil, "noop")
}
func TestExecutorForceKillTimerStop(t *testing.T) {
done := make(chan struct{}, 1)
ft := &forceKillTimer{timer: time.AfterFunc(50*time.Millisecond, func() { done <- struct{}{} }), done: done}
ft.Stop()
done2 := make(chan struct{}, 1)
ft2 := &forceKillTimer{timer: time.AfterFunc(0, func() { done2 <- struct{}{} }), done: done2}
time.Sleep(10 * time.Millisecond)
ft2.Stop()
var nilTimer *forceKillTimer
nilTimer.Stop()
(&forceKillTimer{}).Stop()
}
func TestExecutorForwardSignalsDefaults(t *testing.T) {
origNotify := signalNotifyFn
origStop := signalStopFn
signalNotifyFn = nil
signalStopFn = nil
defer func() {
signalNotifyFn = origNotify
signalStopFn = origStop
}()
ctx, cancel := context.WithCancel(context.Background())
cancel()
forwardSignals(ctx, &execFakeRunner{process: &execFakeProcess{pid: 80}}, func(string) {})
time.Sleep(10 * time.Millisecond)
}

3
codeagent-wrapper/go.mod Normal file
View File

@@ -0,0 +1,3 @@
module codeagent-wrapper
go 1.21

View File

@@ -0,0 +1,39 @@
package main
import (
"os"
"strings"
"testing"
)
func TestLogWriterWriteLimitsBuffer(t *testing.T) {
defer resetTestHooks()
logger, err := NewLogger()
if err != nil {
t.Fatalf("NewLogger error: %v", err)
}
setLogger(logger)
defer closeLogger()
lw := newLogWriter("P:", 10)
_, _ = lw.Write([]byte(strings.Repeat("a", 100)))
if lw.buf.Len() != 10 {
t.Fatalf("logWriter buffer len=%d, want %d", lw.buf.Len(), 10)
}
if !lw.dropped {
t.Fatalf("expected logWriter to drop overlong line bytes")
}
lw.Flush()
logger.Flush()
data, err := os.ReadFile(logger.Path())
if err != nil {
t.Fatalf("ReadFile error: %v", err)
}
if !strings.Contains(string(data), "P:aaaaaaa...") {
t.Fatalf("log output missing truncated entry, got %q", string(data))
}
}

View File

@@ -64,15 +64,15 @@ func NewLogger() (*Logger, error) {
// NewLoggerWithSuffix creates a logger with an optional suffix in the filename.
// Useful for tests that need isolated log files within the same process.
func NewLoggerWithSuffix(suffix string) (*Logger, error) {
filename := fmt.Sprintf("codex-wrapper-%d", os.Getpid())
filename := fmt.Sprintf("%s-%d", primaryLogPrefix(), os.Getpid())
if suffix != "" {
filename += "-" + suffix
}
filename += ".log"
path := filepath.Join(os.TempDir(), filename)
path := filepath.Clean(filepath.Join(os.TempDir(), filename))
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
@@ -156,7 +156,7 @@ func (l *Logger) Close() error {
}
// Log file is kept for debugging - NOT removed
// Users can manually clean up /tmp/codex-wrapper-*.log files
// Users can manually clean up /tmp/<wrapper>-*.log files
})
return closeErr
@@ -170,6 +170,36 @@ func (l *Logger) RemoveLogFile() error {
return os.Remove(l.path)
}
// ExtractRecentErrors reads the log file and returns the most recent ERROR and WARN entries.
// Returns up to maxEntries entries in chronological order.
func (l *Logger) ExtractRecentErrors(maxEntries int) []string {
if l == nil || l.path == "" {
return nil
}
f, err := os.Open(l.path)
if err != nil {
return nil
}
defer f.Close()
var entries []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if strings.Contains(line, "] ERROR:") || strings.Contains(line, "] WARN:") {
entries = append(entries, line)
}
}
// Keep only the last maxEntries
if len(entries) > maxEntries {
entries = entries[len(entries)-maxEntries:]
}
return entries
}
// Flush waits for all pending log entries to be written. Primarily for tests.
// Returns after a 5-second timeout to prevent indefinite blocking.
func (l *Logger) Flush() {
@@ -250,7 +280,7 @@ func (l *Logger) run() {
case entry, ok := <-l.ch:
if !ok {
// Channel closed, final flush
l.writer.Flush()
_ = l.writer.Flush()
return
}
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
@@ -259,18 +289,18 @@ func (l *Logger) run() {
l.pendingWG.Done()
case <-ticker.C:
l.writer.Flush()
_ = l.writer.Flush()
case flushDone := <-l.flushReq:
// Explicit flush request - flush writer and sync to disk
l.writer.Flush()
l.file.Sync()
_ = l.writer.Flush()
_ = l.file.Sync()
close(flushDone)
}
}
}
// cleanupOldLogs scans os.TempDir() for codex-wrapper-*.log files and removes those
// cleanupOldLogs scans os.TempDir() for wrapper log files and removes those
// whose owning process is no longer running (i.e., orphaned logs).
// It includes safety checks for:
// - PID reuse: Compares file modification time with process start time
@@ -278,12 +308,28 @@ func (l *Logger) run() {
func cleanupOldLogs() (CleanupStats, error) {
var stats CleanupStats
tempDir := os.TempDir()
pattern := filepath.Join(tempDir, "codex-wrapper-*.log")
matches, err := globLogFiles(pattern)
if err != nil {
logWarn(fmt.Sprintf("cleanupOldLogs: failed to list logs: %v", err))
return stats, fmt.Errorf("cleanupOldLogs: %w", err)
prefixes := logPrefixes()
if len(prefixes) == 0 {
prefixes = []string{defaultWrapperName}
}
seen := make(map[string]struct{})
var matches []string
for _, prefix := range prefixes {
pattern := filepath.Join(tempDir, fmt.Sprintf("%s-*.log", prefix))
found, err := globLogFiles(pattern)
if err != nil {
logWarn(fmt.Sprintf("cleanupOldLogs: failed to list logs: %v", err))
return stats, fmt.Errorf("cleanupOldLogs: %w", err)
}
for _, path := range found {
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
matches = append(matches, path)
}
}
var removeErr error
@@ -428,28 +474,60 @@ func isPIDReused(logPath string, pid int) bool {
func parsePIDFromLog(path string) (int, bool) {
name := filepath.Base(path)
if !strings.HasPrefix(name, "codex-wrapper-") || !strings.HasSuffix(name, ".log") {
return 0, false
prefixes := logPrefixes()
if len(prefixes) == 0 {
prefixes = []string{defaultWrapperName}
}
core := strings.TrimSuffix(strings.TrimPrefix(name, "codex-wrapper-"), ".log")
if core == "" {
return 0, false
for _, prefix := range prefixes {
prefixWithDash := fmt.Sprintf("%s-", prefix)
if !strings.HasPrefix(name, prefixWithDash) || !strings.HasSuffix(name, ".log") {
continue
}
core := strings.TrimSuffix(strings.TrimPrefix(name, prefixWithDash), ".log")
if core == "" {
continue
}
pidPart := core
if idx := strings.IndexRune(core, '-'); idx != -1 {
pidPart = core[:idx]
}
if pidPart == "" {
continue
}
pid, err := strconv.Atoi(pidPart)
if err != nil || pid <= 0 {
continue
}
return pid, true
}
pidPart := core
if idx := strings.IndexRune(core, '-'); idx != -1 {
pidPart = core[:idx]
}
if pidPart == "" {
return 0, false
}
pid, err := strconv.Atoi(pidPart)
if err != nil || pid <= 0 {
return 0, false
}
return pid, true
return 0, false
}
func logConcurrencyPlanning(limit, total int) {
logger := activeLogger()
if logger == nil {
return
}
logger.Info(fmt.Sprintf("parallel: worker_limit=%s total_tasks=%d", renderWorkerLimit(limit), total))
}
func logConcurrencyState(event, taskID string, active, limit int) {
logger := activeLogger()
if logger == nil {
return
}
logger.Debug(fmt.Sprintf("parallel: %s task=%s active=%d limit=%s", event, taskID, active, renderWorkerLimit(limit)))
}
func renderWorkerLimit(limit int) string {
if limit <= 0 {
return "unbounded"
}
return strconv.Itoa(limit)
}

View File

@@ -36,7 +36,7 @@ func TestRunLoggerCreatesFileWithPID(t *testing.T) {
}
defer logger.Close()
expectedPath := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
expectedPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
if logger.Path() != expectedPath {
t.Fatalf("logger path = %s, want %s", logger.Path(), expectedPath)
}
@@ -171,7 +171,7 @@ func TestRunLoggerTerminateProcessActive(t *testing.T) {
t.Skipf("cannot start sleep command: %v", err)
}
timer := terminateProcess(cmd)
timer := terminateProcess(&realCmd{cmd: cmd})
if timer == nil {
t.Fatalf("terminateProcess returned nil timer for active process")
}
@@ -197,7 +197,7 @@ func TestRunTerminateProcessNil(t *testing.T) {
if timer := terminateProcess(nil); timer != nil {
t.Fatalf("terminateProcess(nil) should return nil timer")
}
if timer := terminateProcess(&exec.Cmd{}); timer != nil {
if timer := terminateProcess(&realCmd{cmd: &exec.Cmd{}}); timer != nil {
t.Fatalf("terminateProcess with nil process should return nil timer")
}
}
@@ -477,13 +477,13 @@ func TestRunCleanupOldLogsPerformanceBound(t *testing.T) {
}
func TestRunCleanupOldLogsCoverageSuite(t *testing.T) {
TestRunParseJSONStream_CoverageSuite(t)
TestBackendParseJSONStream_CoverageSuite(t)
}
// Reuse the existing coverage suite so the focused TestLogger run still exercises
// the rest of the codebase and keeps coverage high.
func TestRunLoggerCoverageSuite(t *testing.T) {
TestRunParseJSONStream_CoverageSuite(t)
TestBackendParseJSONStream_CoverageSuite(t)
}
func TestRunCleanupOldLogsKeepsCurrentProcessLog(t *testing.T) {
@@ -768,3 +768,101 @@ func (f fakeFileInfo) Mode() os.FileMode { return f.mode }
func (f fakeFileInfo) ModTime() time.Time { return f.modTime }
func (f fakeFileInfo) IsDir() bool { return false }
func (f fakeFileInfo) Sys() interface{} { return nil }
func TestExtractRecentErrors(t *testing.T) {
tests := []struct {
name string
content string
maxEntries int
want []string
}{
{
name: "empty log",
content: "",
maxEntries: 10,
want: nil,
},
{
name: "no errors",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
[2025-01-01 12:00:01.000] [PID:123] DEBUG: processing`,
maxEntries: 10,
want: nil,
},
{
name: "single error",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed`,
maxEntries: 10,
want: []string{"[2025-01-01 12:00:01.000] [PID:123] ERROR: something failed"},
},
{
name: "error and warn",
content: `[2025-01-01 12:00:00.000] [PID:123] INFO: started
[2025-01-01 12:00:01.000] [PID:123] WARN: warning message
[2025-01-01 12:00:02.000] [PID:123] ERROR: error message`,
maxEntries: 10,
want: []string{
"[2025-01-01 12:00:01.000] [PID:123] WARN: warning message",
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error message",
},
},
{
name: "truncate to max",
content: `[2025-01-01 12:00:00.000] [PID:123] ERROR: error 1
[2025-01-01 12:00:01.000] [PID:123] ERROR: error 2
[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3
[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4
[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5`,
maxEntries: 3,
want: []string{
"[2025-01-01 12:00:02.000] [PID:123] ERROR: error 3",
"[2025-01-01 12:00:03.000] [PID:123] ERROR: error 4",
"[2025-01-01 12:00:04.000] [PID:123] ERROR: error 5",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tempDir := t.TempDir()
logPath := filepath.Join(tempDir, "test.log")
if err := os.WriteFile(logPath, []byte(tt.content), 0o644); err != nil {
t.Fatalf("failed to write test log: %v", err)
}
logger := &Logger{path: logPath}
got := logger.ExtractRecentErrors(tt.maxEntries)
if len(got) != len(tt.want) {
t.Fatalf("ExtractRecentErrors() got %d entries, want %d", len(got), len(tt.want))
}
for i, entry := range got {
if entry != tt.want[i] {
t.Errorf("entry[%d] = %q, want %q", i, entry, tt.want[i])
}
}
})
}
}
func TestExtractRecentErrorsNilLogger(t *testing.T) {
var logger *Logger
if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("nil logger ExtractRecentErrors() should return nil, got %v", got)
}
}
func TestExtractRecentErrorsEmptyPath(t *testing.T) {
logger := &Logger{path: ""}
if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("empty path ExtractRecentErrors() should return nil, got %v", got)
}
}
func TestExtractRecentErrorsFileNotExist(t *testing.T) {
logger := &Logger{path: "/nonexistent/path/to/log.log"}
if got := logger.ExtractRecentErrors(10); got != nil {
t.Fatalf("nonexistent file ExtractRecentErrors() should return nil, got %v", got)
}
}

469
codeagent-wrapper/main.go Normal file
View File

@@ -0,0 +1,469 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"reflect"
"strings"
"sync/atomic"
"time"
)
const (
version = "5.2.3"
defaultWorkdir = "."
defaultTimeout = 7200 // seconds
codexLogLineLimit = 1000
stdinSpecialChars = "\n\\\"'`$"
stderrCaptureLimit = 4 * 1024
defaultBackendName = "codex"
defaultCodexCommand = "codex"
// stdout close reasons
stdoutCloseReasonWait = "wait-done"
stdoutCloseReasonDrain = "drain-timeout"
stdoutCloseReasonCtx = "context-cancel"
stdoutDrainTimeout = 100 * time.Millisecond
)
// Test hooks for dependency injection
var (
stdinReader io.Reader = os.Stdin
isTerminalFn = defaultIsTerminal
codexCommand = defaultCodexCommand
cleanupHook func()
loggerPtr atomic.Pointer[Logger]
buildCodexArgsFn = buildCodexArgs
selectBackendFn = selectBackend
commandContext = exec.CommandContext
jsonMarshal = json.Marshal
cleanupLogsFn = cleanupOldLogs
signalNotifyFn = signal.Notify
signalStopFn = signal.Stop
terminateCommandFn = terminateCommand
defaultBuildArgsFn = buildCodexArgs
runTaskFn = runCodexTask
exitFn = os.Exit
)
var forceKillDelay atomic.Int32
func init() {
forceKillDelay.Store(5) // seconds - default value
}
func runStartupCleanup() {
if cleanupLogsFn == nil {
return
}
defer func() {
if r := recover(); r != nil {
logWarn(fmt.Sprintf("cleanupOldLogs panic: %v", r))
}
}()
if _, err := cleanupLogsFn(); err != nil {
logWarn(fmt.Sprintf("cleanupOldLogs error: %v", err))
}
}
func runCleanupMode() int {
if cleanupLogsFn == nil {
fmt.Fprintln(os.Stderr, "Cleanup failed: log cleanup function not configured")
return 1
}
stats, err := cleanupLogsFn()
if err != nil {
fmt.Fprintf(os.Stderr, "Cleanup failed: %v\n", err)
return 1
}
fmt.Println("Cleanup completed")
fmt.Printf("Files scanned: %d\n", stats.Scanned)
fmt.Printf("Files deleted: %d\n", stats.Deleted)
if len(stats.DeletedFiles) > 0 {
for _, f := range stats.DeletedFiles {
fmt.Printf(" - %s\n", f)
}
}
fmt.Printf("Files kept: %d\n", stats.Kept)
if len(stats.KeptFiles) > 0 {
for _, f := range stats.KeptFiles {
fmt.Printf(" - %s\n", f)
}
}
if stats.Errors > 0 {
fmt.Printf("Deletion errors: %d\n", stats.Errors)
}
return 0
}
func main() {
exitCode := run()
exitFn(exitCode)
}
// run is the main logic, returns exit code for testability
func run() (exitCode int) {
name := currentWrapperName()
// Handle --version and --help first (no logger needed)
if len(os.Args) > 1 {
switch os.Args[1] {
case "--version", "-v":
fmt.Printf("%s version %s\n", name, version)
return 0
case "--help", "-h":
printHelp()
return 0
case "--cleanup":
return runCleanupMode()
}
}
// Initialize logger for all other commands
logger, err := NewLogger()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err)
return 1
}
setLogger(logger)
defer func() {
logger := activeLogger()
if logger != nil {
logger.Flush()
}
if err := closeLogger(); err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to close logger: %v\n", err)
}
// On failure, extract and display recent errors before removing log
if logger != nil {
if exitCode != 0 {
if errors := logger.ExtractRecentErrors(10); len(errors) > 0 {
fmt.Fprintln(os.Stderr, "\n=== Recent Errors ===")
for _, entry := range errors {
fmt.Fprintln(os.Stderr, entry)
}
fmt.Fprintf(os.Stderr, "Log file: %s (deleted)\n", logger.Path())
}
}
if err := logger.RemoveLogFile(); err != nil && !os.IsNotExist(err) {
// Silently ignore removal errors
}
}
}()
defer runCleanupHook()
// Clean up stale logs from previous runs.
runStartupCleanup()
// Handle remaining commands
if len(os.Args) > 1 {
args := os.Args[1:]
parallelIndex := -1
for i, arg := range args {
if arg == "--parallel" {
parallelIndex = i
break
}
}
if parallelIndex != -1 {
backendName := defaultBackendName
var extras []string
for i := 0; i < len(args); i++ {
arg := args[i]
switch {
case arg == "--parallel":
continue
case arg == "--backend":
if i+1 >= len(args) {
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
return 1
}
backendName = args[i+1]
i++
case strings.HasPrefix(arg, "--backend="):
value := strings.TrimPrefix(arg, "--backend=")
if value == "" {
fmt.Fprintln(os.Stderr, "ERROR: --backend flag requires a value")
return 1
}
backendName = value
default:
extras = append(extras, arg)
}
}
if len(extras) > 0 {
fmt.Fprintln(os.Stderr, "ERROR: --parallel reads its task configuration from stdin; only --backend is 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)
return 1
}
backend, err := selectBackendFn(backendName)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
backendName = backend.Name()
data, err := io.ReadAll(stdinReader)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: failed to read stdin: %v\n", err)
return 1
}
cfg, err := parseParallelConfig(data)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
cfg.GlobalBackend = backendName
for i := range cfg.Tasks {
if strings.TrimSpace(cfg.Tasks[i].Backend) == "" {
cfg.Tasks[i].Backend = backendName
}
}
timeoutSec := resolveTimeout()
layers, err := topologicalSort(cfg.Tasks)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
return 1
}
results := executeConcurrent(layers, timeoutSec)
fmt.Println(generateFinalOutput(results))
exitCode = 0
for _, res := range results {
if res.ExitCode != 0 {
exitCode = res.ExitCode
}
}
return exitCode
}
}
logInfo("Script started")
cfg, err := parseArgs()
if err != nil {
logError(err.Error())
return 1
}
logInfo(fmt.Sprintf("Parsed args: mode=%s, task_len=%d, backend=%s", cfg.Mode, len(cfg.Task), cfg.Backend))
backend, err := selectBackendFn(cfg.Backend)
if err != nil {
logError(err.Error())
return 1
}
cfg.Backend = backend.Name()
cmdInjected := codexCommand != defaultCodexCommand
argsInjected := buildCodexArgsFn != nil && reflect.ValueOf(buildCodexArgsFn).Pointer() != reflect.ValueOf(defaultBuildArgsFn).Pointer()
// Wire selected backend into runtime hooks for the rest of the execution,
// but preserve any injected test hooks for the default backend.
if backend.Name() != defaultBackendName || !cmdInjected {
codexCommand = backend.Command()
}
if backend.Name() != defaultBackendName || !argsInjected {
buildCodexArgsFn = backend.BuildArgs
}
logInfo(fmt.Sprintf("Selected backend: %s", backend.Name()))
timeoutSec := resolveTimeout()
logInfo(fmt.Sprintf("Timeout: %ds", timeoutSec))
cfg.Timeout = timeoutSec
var taskText string
var piped bool
if cfg.ExplicitStdin {
logInfo("Explicit stdin mode: reading task from stdin")
data, err := io.ReadAll(stdinReader)
if err != nil {
logError("Failed to read stdin: " + err.Error())
return 1
}
taskText = string(data)
if taskText == "" {
logError("Explicit stdin mode requires task input from stdin")
return 1
}
piped = !isTerminal()
} else {
pipedTask, err := readPipedTask()
if err != nil {
logError("Failed to read piped stdin: " + err.Error())
return 1
}
piped = pipedTask != ""
if piped {
taskText = pipedTask
} else {
taskText = cfg.Task
}
}
useStdin := cfg.ExplicitStdin || shouldUseStdin(taskText, piped)
targetArg := taskText
if useStdin {
targetArg = "-"
}
codexArgs := buildCodexArgsFn(cfg, targetArg)
// Print startup information to stderr
fmt.Fprintf(os.Stderr, "[%s]\n", name)
fmt.Fprintf(os.Stderr, " Backend: %s\n", cfg.Backend)
fmt.Fprintf(os.Stderr, " Command: %s %s\n", codexCommand, strings.Join(codexArgs, " "))
fmt.Fprintf(os.Stderr, " PID: %d\n", os.Getpid())
fmt.Fprintf(os.Stderr, " Log: %s\n", logger.Path())
if useStdin {
var reasons []string
if piped {
reasons = append(reasons, "piped input")
}
if cfg.ExplicitStdin {
reasons = append(reasons, "explicit \"-\"")
}
if strings.Contains(taskText, "\n") {
reasons = append(reasons, "newline")
}
if strings.Contains(taskText, "\\") {
reasons = append(reasons, "backslash")
}
if strings.Contains(taskText, "\"") {
reasons = append(reasons, "double-quote")
}
if strings.Contains(taskText, "'") {
reasons = append(reasons, "single-quote")
}
if strings.Contains(taskText, "`") {
reasons = append(reasons, "backtick")
}
if strings.Contains(taskText, "$") {
reasons = append(reasons, "dollar")
}
if len(taskText) > 800 {
reasons = append(reasons, "length>800")
}
if len(reasons) > 0 {
logWarn(fmt.Sprintf("Using stdin mode for task due to: %s", strings.Join(reasons, ", ")))
}
}
logInfo(fmt.Sprintf("%s running...", cfg.Backend))
taskSpec := TaskSpec{
Task: taskText,
WorkDir: cfg.WorkDir,
Mode: cfg.Mode,
SessionID: cfg.SessionID,
UseStdin: useStdin,
}
result := runTaskFn(taskSpec, false, cfg.Timeout)
if result.ExitCode != 0 {
return result.ExitCode
}
fmt.Println(result.Message)
if result.SessionID != "" {
fmt.Printf("\n---\nSESSION_ID: %s\n", result.SessionID)
}
return 0
}
func setLogger(l *Logger) {
loggerPtr.Store(l)
}
func closeLogger() error {
logger := loggerPtr.Swap(nil)
if logger == nil {
return nil
}
return logger.Close()
}
func activeLogger() *Logger {
return loggerPtr.Load()
}
func logInfo(msg string) {
if logger := activeLogger(); logger != nil {
logger.Info(msg)
}
}
func logWarn(msg string) {
if logger := activeLogger(); logger != nil {
logger.Warn(msg)
}
}
func logError(msg string) {
if logger := activeLogger(); logger != nil {
logger.Error(msg)
}
}
func runCleanupHook() {
if logger := activeLogger(); logger != nil {
logger.Flush()
}
if cleanupHook != nil {
cleanupHook()
}
}
func printHelp() {
name := currentWrapperName()
help := fmt.Sprintf(`%[1]s - Go wrapper for AI CLI backends
Usage:
%[1]s "task" [workdir]
%[1]s --backend claude "task" [workdir]
%[1]s - [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 --version
%[1]s --help
Parallel mode examples:
%[1]s --parallel < tasks.txt
echo '...' | %[1]s --parallel
%[1]s --parallel <<'EOF'
Environment Variables:
CODEX_TIMEOUT Timeout in milliseconds (default: 7200000)
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)
}

View File

@@ -80,6 +80,8 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
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:"))
} else if line != "" && !strings.HasPrefix(line, "===") && !strings.HasPrefix(line, "---") {
if currentTask.Message != "" {
currentTask.Message += "\n"
@@ -96,6 +98,32 @@ func parseIntegrationOutput(t *testing.T, out string) integrationOutput {
return payload
}
func extractTaskBlock(t *testing.T, output, taskID string) string {
t.Helper()
header := fmt.Sprintf("--- Task: %s ---", taskID)
lines := strings.Split(output, "\n")
var block []string
collecting := false
for _, raw := range lines {
trimmed := strings.TrimSpace(raw)
if !collecting {
if trimmed == header {
collecting = true
block = append(block, trimmed)
}
continue
}
if strings.HasPrefix(trimmed, "--- Task: ") && trimmed != header {
break
}
block = append(block, trimmed)
}
if len(block) == 0 {
t.Fatalf("task block %s not found in output:\n%s", taskID, output)
}
return strings.Join(block, "\n")
}
func findResultByID(t *testing.T, payload integrationOutput, id string) TaskResult {
t.Helper()
for _, res := range payload.Results {
@@ -138,7 +166,7 @@ id: E
---CONTENT---
task-e`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
os.Args = []string{"codeagent-wrapper", "--parallel"}
var mu sync.Mutex
starts := make(map[string]time.Time)
@@ -241,7 +269,7 @@ dependencies: A
---CONTENT---
b`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
os.Args = []string{"codeagent-wrapper", "--parallel"}
exitCode := 0
output := captureStdout(t, func() {
@@ -256,6 +284,194 @@ b`
}
}
func TestRunParallelOutputsIncludeLogPaths(t *testing.T) {
defer resetTestHooks()
origRun := runCodexTaskFn
t.Cleanup(func() {
runCodexTaskFn = origRun
resetTestHooks()
})
tempDir := t.TempDir()
logPathFor := func(id string) string {
return filepath.Join(tempDir, fmt.Sprintf("%s.log", id))
}
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
res := TaskResult{
TaskID: task.ID,
Message: fmt.Sprintf("result-%s", task.ID),
SessionID: fmt.Sprintf("session-%s", task.ID),
LogPath: logPathFor(task.ID),
}
if task.ID == "beta" {
res.ExitCode = 9
res.Error = "boom"
}
return res
}
input := `---TASK---
id: alpha
---CONTENT---
task-alpha
---TASK---
id: beta
---CONTENT---
task-beta`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
var exitCode int
output := captureStdout(t, func() {
exitCode = run()
})
if exitCode != 9 {
t.Fatalf("parallel run exit=%d, want 9", exitCode)
}
payload := parseIntegrationOutput(t, output)
alpha := findResultByID(t, payload, "alpha")
beta := findResultByID(t, payload, "beta")
if alpha.LogPath != logPathFor("alpha") {
t.Fatalf("alpha log path = %q, want %q", alpha.LogPath, logPathFor("alpha"))
}
if beta.LogPath != logPathFor("beta") {
t.Fatalf("beta log path = %q, want %q", beta.LogPath, logPathFor("beta"))
}
for _, id := range []string{"alpha", "beta"} {
want := fmt.Sprintf("Log: %s", logPathFor(id))
if !strings.Contains(output, want) {
t.Fatalf("parallel output missing %q for %s:\n%s", want, id, output)
}
}
}
func TestRunParallelStartupLogsPrinted(t *testing.T) {
defer resetTestHooks()
tempDir := setTempDirEnv(t, t.TempDir())
input := `---TASK---
id: a
---CONTENT---
fail
---TASK---
id: b
---CONTENT---
ok-b
---TASK---
id: c
dependencies: a
---CONTENT---
should-skip
---TASK---
id: d
---CONTENT---
ok-d`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
origRun := runCodexTaskFn
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
path := expectedLog
if logger := activeLogger(); logger != nil && logger.Path() != "" {
path = logger.Path()
}
if task.ID == "a" {
return TaskResult{TaskID: task.ID, ExitCode: 3, Error: "boom", LogPath: path}
}
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: task.Task, LogPath: path}
}
t.Cleanup(func() { runCodexTaskFn = origRun })
var exitCode int
var stdoutOut string
stderrOut := captureStderr(t, func() {
stdoutOut = captureStdout(t, func() {
exitCode = run()
})
})
if exitCode == 0 {
t.Fatalf("expected non-zero exit due to task failure, got %d", exitCode)
}
if stdoutOut == "" {
t.Fatalf("expected parallel summary on stdout")
}
lines := strings.Split(strings.TrimSpace(stderrOut), "\n")
var bannerSeen bool
var taskLines []string
for _, raw := range lines {
line := strings.TrimSpace(raw)
if line == "" {
continue
}
if line == "=== Starting Parallel Execution ===" {
if bannerSeen {
t.Fatalf("banner printed multiple times:\n%s", stderrOut)
}
bannerSeen = true
continue
}
taskLines = append(taskLines, line)
}
if !bannerSeen {
t.Fatalf("expected startup banner in stderr, got:\n%s", stderrOut)
}
expectedLines := map[string]struct{}{
fmt.Sprintf("Task a: Log: %s", expectedLog): {},
fmt.Sprintf("Task b: Log: %s", expectedLog): {},
fmt.Sprintf("Task d: Log: %s", expectedLog): {},
}
if len(taskLines) != len(expectedLines) {
t.Fatalf("startup log lines mismatch, got %d lines:\n%s", len(taskLines), stderrOut)
}
for _, line := range taskLines {
if _, ok := expectedLines[line]; !ok {
t.Fatalf("unexpected startup line %q\nstderr:\n%s", line, stderrOut)
}
}
}
func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
defer resetTestHooks()
tempDir := setTempDirEnv(t, t.TempDir())
os.Args = []string{"codex-wrapper", "integration-log-check"}
stdinReader = strings.NewReader("")
isTerminalFn = func() bool { return true }
codexCommand = "echo"
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
return []string{`{"type":"thread.started","thread_id":"integration-session"}` + "\n" + `{"type":"item.completed","item":{"type":"agent_message","text":"done"}}`}
}
var exitCode int
stderr := captureStderr(t, func() {
_ = captureStdout(t, func() {
exitCode = run()
})
})
if exitCode != 0 {
t.Fatalf("run() exit=%d, want 0", exitCode)
}
expectedLog := filepath.Join(tempDir, fmt.Sprintf("codex-wrapper-%d.log", os.Getpid()))
wantLine := fmt.Sprintf("Log: %s", expectedLog)
if !strings.Contains(stderr, wantLine) {
t.Fatalf("stderr missing %q, got: %q", wantLine, stderr)
}
}
func TestRunParallelPartialFailureBlocksDependents(t *testing.T) {
defer resetTestHooks()
origRun := runCodexTaskFn
@@ -264,11 +480,17 @@ func TestRunParallelPartialFailureBlocksDependents(t *testing.T) {
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"}
return TaskResult{TaskID: "A", ExitCode: 2, Error: "boom", LogPath: path}
}
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: task.Task}
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: task.Task, LogPath: path}
}
input := `---TASK---
@@ -289,7 +511,7 @@ id: E
---CONTENT---
ok-e`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
os.Args = []string{"codeagent-wrapper", "--parallel"}
var exitCode int
output := captureStdout(t, func() {
@@ -318,6 +540,26 @@ ok-e`
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)
}
for _, id := range []string{"A", "D", "E"} {
block := extractTaskBlock(t, output, id)
want := fmt.Sprintf("Log: %s", logPathFor(id))
if !strings.Contains(block, want) {
t.Fatalf("task %s block missing %q:\n%s", id, want, block)
}
}
blockB := extractTaskBlock(t, output, "B")
if strings.Contains(blockB, "Log:") {
t.Fatalf("skipped task B should not emit a log line:\n%s", blockB)
}
}
func TestRunParallelTimeoutPropagation(t *testing.T) {
@@ -341,7 +583,7 @@ id: T
---CONTENT---
slow`
stdinReader = bytes.NewReader([]byte(input))
os.Args = []string{"codex-wrapper", "--parallel"}
os.Args = []string{"codeagent-wrapper", "--parallel"}
exitCode := 0
output := captureStdout(t, func() {

File diff suppressed because it is too large Load Diff

346
codeagent-wrapper/parser.go Normal file
View File

@@ -0,0 +1,346 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"strings"
)
// JSONEvent represents a Codex JSON output event
type JSONEvent struct {
Type string `json:"type"`
ThreadID string `json:"thread_id,omitempty"`
Item *EventItem `json:"item,omitempty"`
}
// EventItem represents the item field in a JSON event
type EventItem struct {
Type string `json:"type"`
Text interface{} `json:"text"`
}
// ClaudeEvent for Claude stream-json format
type ClaudeEvent struct {
Type string `json:"type"`
Subtype string `json:"subtype,omitempty"`
SessionID string `json:"session_id,omitempty"`
Result string `json:"result,omitempty"`
}
// GeminiEvent for Gemini stream-json format
type GeminiEvent struct {
Type string `json:"type"`
SessionID string `json:"session_id,omitempty"`
Role string `json:"role,omitempty"`
Content string `json:"content,omitempty"`
Delta bool `json:"delta,omitempty"`
Status string `json:"status,omitempty"`
}
func parseJSONStream(r io.Reader) (message, threadID string) {
return parseJSONStreamWithLog(r, logWarn, logInfo)
}
func parseJSONStreamWithWarn(r io.Reader, warnFn func(string)) (message, threadID string) {
return parseJSONStreamWithLog(r, warnFn, logInfo)
}
func parseJSONStreamWithLog(r io.Reader, warnFn func(string), infoFn func(string)) (message, threadID string) {
return parseJSONStreamInternal(r, warnFn, infoFn, nil)
}
const (
jsonLineReaderSize = 64 * 1024
jsonLineMaxBytes = 10 * 1024 * 1024
jsonLinePreviewBytes = 256
)
type codexHeader struct {
Type string `json:"type"`
ThreadID string `json:"thread_id,omitempty"`
Item *struct {
Type string `json:"type"`
} `json:"item,omitempty"`
}
func parseJSONStreamInternal(r io.Reader, warnFn func(string), infoFn func(string), onMessage func()) (message, threadID string) {
reader := bufio.NewReaderSize(r, jsonLineReaderSize)
if warnFn == nil {
warnFn = func(string) {}
}
if infoFn == nil {
infoFn = func(string) {}
}
notifyMessage := func() {
if onMessage != nil {
onMessage()
}
}
totalEvents := 0
var (
codexMessage string
claudeMessage string
geminiBuffer strings.Builder
)
for {
line, tooLong, err := readLineWithLimit(reader, jsonLineMaxBytes, jsonLinePreviewBytes)
if err != nil {
if errors.Is(err, io.EOF) {
break
}
warnFn("Read stdout error: " + err.Error())
break
}
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
totalEvents++
if tooLong {
warnFn(fmt.Sprintf("Skipped overlong JSON line (> %d bytes): %s", jsonLineMaxBytes, truncateBytes(line, 100)))
continue
}
var codex codexHeader
if err := json.Unmarshal(line, &codex); err == nil {
isCodex := codex.ThreadID != "" || (codex.Item != nil && codex.Item.Type != "")
if isCodex {
var details []string
if codex.ThreadID != "" {
details = append(details, fmt.Sprintf("thread_id=%s", codex.ThreadID))
}
if codex.Item != nil && codex.Item.Type != "" {
details = append(details, fmt.Sprintf("item_type=%s", codex.Item.Type))
}
if len(details) > 0 {
infoFn(fmt.Sprintf("Parsed event #%d type=%s (%s)", totalEvents, codex.Type, strings.Join(details, ", ")))
} else {
infoFn(fmt.Sprintf("Parsed event #%d type=%s", totalEvents, codex.Type))
}
switch codex.Type {
case "thread.started":
threadID = codex.ThreadID
infoFn(fmt.Sprintf("thread.started event thread_id=%s", threadID))
case "item.completed":
itemType := ""
if codex.Item != nil {
itemType = codex.Item.Type
}
if itemType == "agent_message" {
var event JSONEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Codex event: %s", truncateBytes(line, 100)))
continue
}
normalized := ""
if event.Item != nil {
normalized = normalizeText(event.Item.Text)
}
infoFn(fmt.Sprintf("item.completed event item_type=%s message_len=%d", itemType, len(normalized)))
if normalized != "" {
codexMessage = normalized
notifyMessage()
}
} else {
infoFn(fmt.Sprintf("item.completed event item_type=%s", itemType))
}
}
continue
}
}
var raw map[string]json.RawMessage
if err := json.Unmarshal(line, &raw); err != nil {
warnFn(fmt.Sprintf("Failed to parse line: %s", truncateBytes(line, 100)))
continue
}
switch {
case hasKey(raw, "subtype") || hasKey(raw, "result"):
var event ClaudeEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Claude event: %s", truncateBytes(line, 100)))
continue
}
if event.SessionID != "" && threadID == "" {
threadID = event.SessionID
}
infoFn(fmt.Sprintf("Parsed Claude event #%d type=%s subtype=%s result_len=%d", totalEvents, event.Type, event.Subtype, len(event.Result)))
if event.Result != "" {
claudeMessage = event.Result
notifyMessage()
}
case hasKey(raw, "role") || hasKey(raw, "delta"):
var event GeminiEvent
if err := json.Unmarshal(line, &event); err != nil {
warnFn(fmt.Sprintf("Failed to parse Gemini event: %s", truncateBytes(line, 100)))
continue
}
if event.SessionID != "" && threadID == "" {
threadID = event.SessionID
}
if event.Content != "" {
geminiBuffer.WriteString(event.Content)
notifyMessage()
}
infoFn(fmt.Sprintf("Parsed Gemini event #%d type=%s role=%s delta=%t status=%s content_len=%d", totalEvents, event.Type, event.Role, event.Delta, event.Status, len(event.Content)))
default:
warnFn(fmt.Sprintf("Unknown event format: %s", truncateBytes(line, 100)))
}
}
switch {
case geminiBuffer.Len() > 0:
message = geminiBuffer.String()
case claudeMessage != "":
message = claudeMessage
default:
message = codexMessage
}
infoFn(fmt.Sprintf("parseJSONStream completed: events=%d, message_len=%d, thread_id_found=%t", totalEvents, len(message), threadID != ""))
return message, threadID
}
func hasKey(m map[string]json.RawMessage, key string) bool {
_, ok := m[key]
return ok
}
func discardInvalidJSON(decoder *json.Decoder, reader *bufio.Reader) (*bufio.Reader, error) {
var buffered bytes.Buffer
if decoder != nil {
if buf := decoder.Buffered(); buf != nil {
_, _ = buffered.ReadFrom(buf)
}
}
line, err := reader.ReadBytes('\n')
buffered.Write(line)
data := buffered.Bytes()
newline := bytes.IndexByte(data, '\n')
if newline == -1 {
return reader, err
}
remaining := data[newline+1:]
if len(remaining) == 0 {
return reader, err
}
return bufio.NewReader(io.MultiReader(bytes.NewReader(remaining), reader)), err
}
func readLineWithLimit(r *bufio.Reader, maxBytes int, previewBytes int) (line []byte, tooLong bool, err error) {
if r == nil {
return nil, false, errors.New("reader is nil")
}
if maxBytes <= 0 {
return nil, false, errors.New("maxBytes must be > 0")
}
if previewBytes < 0 {
previewBytes = 0
}
part, isPrefix, err := r.ReadLine()
if err != nil {
return nil, false, err
}
if !isPrefix {
if len(part) > maxBytes {
return part[:min(len(part), previewBytes)], true, nil
}
return part, false, nil
}
preview := make([]byte, 0, min(previewBytes, len(part)))
if previewBytes > 0 {
preview = append(preview, part[:min(previewBytes, len(part))]...)
}
buf := make([]byte, 0, min(maxBytes, len(part)*2))
total := 0
if len(part) > maxBytes {
tooLong = true
} else {
buf = append(buf, part...)
total = len(part)
}
for isPrefix {
part, isPrefix, err = r.ReadLine()
if err != nil {
return nil, tooLong, err
}
if previewBytes > 0 && len(preview) < previewBytes {
preview = append(preview, part[:min(previewBytes-len(preview), len(part))]...)
}
if !tooLong {
if total+len(part) > maxBytes {
tooLong = true
continue
}
buf = append(buf, part...)
total += len(part)
}
}
if tooLong {
return preview, true, nil
}
return buf, false, nil
}
func truncateBytes(b []byte, maxLen int) string {
if len(b) <= maxLen {
return string(b)
}
if maxLen < 0 {
return ""
}
return string(b[:maxLen]) + "..."
}
func normalizeText(text interface{}) string {
switch v := text.(type) {
case string:
return v
case []interface{}:
var sb strings.Builder
for _, item := range v {
if s, ok := item.(string); ok {
sb.WriteString(s)
}
}
return sb.String()
default:
return ""
}
}

View File

@@ -0,0 +1,31 @@
package main
import (
"strings"
"testing"
)
func TestParseJSONStream_SkipsOverlongLineAndContinues(t *testing.T) {
// Exceed the 10MB bufio.Scanner limit in parseJSONStreamInternal.
tooLong := strings.Repeat("a", 11*1024*1024)
input := strings.Join([]string{
`{"type":"item.completed","item":{"type":"other_type","text":"` + tooLong + `"}}`,
`{"type":"thread.started","thread_id":"t-1"}`,
`{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`,
}, "\n")
var warns []string
warnFn := func(msg string) { warns = append(warns, msg) }
gotMessage, gotThreadID := parseJSONStreamInternal(strings.NewReader(input), warnFn, nil, nil)
if gotMessage != "ok" {
t.Fatalf("message=%q, want %q (warns=%v)", gotMessage, "ok", warns)
}
if gotThreadID != "t-1" {
t.Fatalf("threadID=%q, want %q (warns=%v)", gotThreadID, "t-1", warns)
}
if len(warns) == 0 || !strings.Contains(warns[0], "Skipped overlong JSON line") {
t.Fatalf("expected warning about overlong JSON line, got %v", warns)
}
}

225
codeagent-wrapper/utils.go Normal file
View File

@@ -0,0 +1,225 @@
package main
import (
"bytes"
"fmt"
"io"
"os"
"strconv"
"strings"
)
func resolveTimeout() int {
raw := os.Getenv("CODEX_TIMEOUT")
if raw == "" {
return defaultTimeout
}
parsed, err := strconv.Atoi(raw)
if err != nil || parsed <= 0 {
logWarn(fmt.Sprintf("Invalid CODEX_TIMEOUT '%s', falling back to %ds", raw, defaultTimeout))
return defaultTimeout
}
if parsed > 10000 {
return parsed / 1000
}
return parsed
}
func readPipedTask() (string, error) {
if isTerminal() {
logInfo("Stdin is tty, skipping pipe read")
return "", nil
}
logInfo("Reading from stdin pipe...")
data, err := io.ReadAll(stdinReader)
if err != nil {
return "", fmt.Errorf("read stdin: %w", err)
}
if len(data) == 0 {
logInfo("Stdin pipe returned empty data")
return "", nil
}
logInfo(fmt.Sprintf("Read %d bytes from stdin pipe", len(data)))
return string(data), nil
}
func shouldUseStdin(taskText string, piped bool) bool {
if piped {
return true
}
if len(taskText) > 800 {
return true
}
return strings.IndexAny(taskText, stdinSpecialChars) >= 0
}
func defaultIsTerminal() bool {
fi, err := os.Stdin.Stat()
if err != nil {
return true
}
return (fi.Mode() & os.ModeCharDevice) != 0
}
func isTerminal() bool {
return isTerminalFn()
}
func getEnv(key, defaultValue string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultValue
}
type logWriter struct {
prefix string
maxLen int
buf bytes.Buffer
dropped bool
}
func newLogWriter(prefix string, maxLen int) *logWriter {
if maxLen <= 0 {
maxLen = codexLogLineLimit
}
return &logWriter{prefix: prefix, maxLen: maxLen}
}
func (lw *logWriter) Write(p []byte) (int, error) {
if lw == nil {
return len(p), nil
}
total := len(p)
for len(p) > 0 {
if idx := bytes.IndexByte(p, '\n'); idx >= 0 {
lw.writeLimited(p[:idx])
lw.logLine(true)
p = p[idx+1:]
continue
}
lw.writeLimited(p)
break
}
return total, nil
}
func (lw *logWriter) Flush() {
if lw == nil || lw.buf.Len() == 0 {
return
}
lw.logLine(false)
}
func (lw *logWriter) logLine(force bool) {
if lw == nil {
return
}
line := lw.buf.String()
dropped := lw.dropped
lw.dropped = false
lw.buf.Reset()
if line == "" && !force {
return
}
if lw.maxLen > 0 {
if dropped {
if lw.maxLen > 3 {
line = line[:min(len(line), lw.maxLen-3)] + "..."
} else {
line = line[:min(len(line), lw.maxLen)]
}
} else if len(line) > lw.maxLen {
cutoff := lw.maxLen
if cutoff > 3 {
line = line[:cutoff-3] + "..."
} else {
line = line[:cutoff]
}
}
}
logInfo(lw.prefix + line)
}
func (lw *logWriter) writeLimited(p []byte) {
if lw == nil || len(p) == 0 {
return
}
if lw.maxLen <= 0 {
lw.buf.Write(p)
return
}
remaining := lw.maxLen - lw.buf.Len()
if remaining <= 0 {
lw.dropped = true
return
}
if len(p) <= remaining {
lw.buf.Write(p)
return
}
lw.buf.Write(p[:remaining])
lw.dropped = true
}
type tailBuffer struct {
limit int
data []byte
}
func (b *tailBuffer) Write(p []byte) (int, error) {
if b.limit <= 0 {
return len(p), nil
}
if len(p) >= b.limit {
b.data = append(b.data[:0], p[len(p)-b.limit:]...)
return len(p), nil
}
total := len(b.data) + len(p)
if total <= b.limit {
b.data = append(b.data, p...)
return len(p), nil
}
overflow := total - b.limit
b.data = append(b.data[overflow:], p...)
return len(p), nil
}
func (b *tailBuffer) String() string {
return string(b.data)
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
if maxLen < 0 {
return ""
}
return s[:maxLen] + "..."
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func hello() string {
return "hello world"
}
func greet(name string) string {
return "hello " + name
}
func farewell(name string) string {
return "goodbye " + name
}

View File

@@ -0,0 +1,126 @@
package main
import (
"os"
"path/filepath"
"strings"
)
const (
defaultWrapperName = "codeagent-wrapper"
legacyWrapperName = "codex-wrapper"
)
var executablePathFn = os.Executable
func normalizeWrapperName(path string) string {
if path == "" {
return ""
}
base := filepath.Base(path)
base = strings.TrimSuffix(base, ".exe") // tolerate Windows executables
switch base {
case defaultWrapperName, legacyWrapperName:
return base
default:
return ""
}
}
// currentWrapperName resolves the wrapper name based on the invoked binary.
// Only known names are honored to avoid leaking build/test binary names into logs.
func currentWrapperName() string {
if len(os.Args) == 0 {
return defaultWrapperName
}
if name := normalizeWrapperName(os.Args[0]); name != "" {
return name
}
execPath, err := executablePathFn()
if err == nil {
if name := normalizeWrapperName(execPath); name != "" {
return name
}
if resolved, err := filepath.EvalSymlinks(execPath); err == nil {
if name := normalizeWrapperName(resolved); name != "" {
return name
}
if alias := resolveAlias(execPath, resolved); alias != "" {
return alias
}
}
if alias := resolveAlias(execPath, ""); alias != "" {
return alias
}
}
return defaultWrapperName
}
// logPrefixes returns the set of accepted log name prefixes, including the
// current wrapper name and legacy aliases.
func logPrefixes() []string {
prefixes := []string{currentWrapperName(), defaultWrapperName, legacyWrapperName}
seen := make(map[string]struct{}, len(prefixes))
var unique []string
for _, prefix := range prefixes {
if prefix == "" {
continue
}
if _, ok := seen[prefix]; ok {
continue
}
seen[prefix] = struct{}{}
unique = append(unique, prefix)
}
return unique
}
// primaryLogPrefix returns the preferred filename prefix for log files.
// Defaults to the current wrapper name when available, otherwise falls back
// to the canonical default name.
func primaryLogPrefix() string {
prefixes := logPrefixes()
if len(prefixes) == 0 {
return defaultWrapperName
}
return prefixes[0]
}
func resolveAlias(execPath string, target string) string {
if execPath == "" {
return ""
}
dir := filepath.Dir(execPath)
for _, candidate := range []string{defaultWrapperName, legacyWrapperName} {
aliasPath := filepath.Join(dir, candidate)
info, err := os.Lstat(aliasPath)
if err != nil {
continue
}
if info.Mode()&os.ModeSymlink == 0 {
continue
}
resolved, err := filepath.EvalSymlinks(aliasPath)
if err != nil {
continue
}
if target != "" && resolved != target {
continue
}
if name := normalizeWrapperName(aliasPath); name != "" {
return name
}
}
return ""
}

View File

@@ -0,0 +1,50 @@
package main
import (
"os"
"path/filepath"
"testing"
)
func TestCurrentWrapperNameFallsBackToExecutable(t *testing.T) {
defer resetTestHooks()
tempDir := t.TempDir()
execPath := filepath.Join(tempDir, "codeagent-wrapper")
if err := os.WriteFile(execPath, []byte("#!/bin/true\n"), 0o755); err != nil {
t.Fatalf("failed to write fake binary: %v", err)
}
os.Args = []string{filepath.Join(tempDir, "custom-name")}
executablePathFn = func() (string, error) {
return execPath, nil
}
if got := currentWrapperName(); got != defaultWrapperName {
t.Fatalf("currentWrapperName() = %q, want %q", got, defaultWrapperName)
}
}
func TestCurrentWrapperNameDetectsLegacyAliasSymlink(t *testing.T) {
defer resetTestHooks()
tempDir := t.TempDir()
execPath := filepath.Join(tempDir, "wrapper")
aliasPath := filepath.Join(tempDir, legacyWrapperName)
if err := os.WriteFile(execPath, []byte("#!/bin/true\n"), 0o755); err != nil {
t.Fatalf("failed to write fake binary: %v", err)
}
if err := os.Symlink(execPath, aliasPath); err != nil {
t.Fatalf("failed to create alias: %v", err)
}
os.Args = []string{filepath.Join(tempDir, "unknown-runner")}
executablePathFn = func() (string, error) {
return execPath, nil
}
if got := currentWrapperName(); got != legacyWrapperName {
t.Fatalf("currentWrapperName() = %q, want %q", got, legacyWrapperName)
}
}

View File

@@ -1,5 +0,0 @@
coverage.out
coverage*.out
cover.out
cover_*.out
coverage.html

View File

@@ -1,3 +0,0 @@
module codex-wrapper
go 1.21

File diff suppressed because it is too large Load Diff

View File

@@ -20,14 +20,38 @@
},
{
"type": "copy_file",
"source": "skills/codex/SKILL.md",
"target": "skills/codex/SKILL.md",
"description": "Install codex skill"
"source": "skills/codeagent/SKILL.md",
"target": "skills/codeagent/SKILL.md",
"description": "Install codeagent skill"
},
{
"type": "copy_file",
"source": "skills/product-requirements/SKILL.md",
"target": "skills/product-requirements/SKILL.md",
"description": "Install product-requirements skill"
},
{
"type": "copy_file",
"source": "skills/prototype-prompt-generator/SKILL.md",
"target": "skills/prototype-prompt-generator/SKILL.md",
"description": "Install prototype-prompt-generator skill"
},
{
"type": "copy_file",
"source": "skills/prototype-prompt-generator/references/prompt-structure.md",
"target": "skills/prototype-prompt-generator/references/prompt-structure.md",
"description": "Install prototype-prompt-generator prompt structure reference"
},
{
"type": "copy_file",
"source": "skills/prototype-prompt-generator/references/design-systems.md",
"target": "skills/prototype-prompt-generator/references/design-systems.md",
"description": "Install prototype-prompt-generator design systems reference"
},
{
"type": "run_command",
"command": "bash install.sh",
"description": "Install codex-wrapper binary",
"description": "Install codeagent-wrapper binary",
"env": {
"INSTALL_DIR": "${install_dir}"
}

View File

@@ -49,6 +49,7 @@
{ "$ref": "#/$defs/op_copy_dir" },
{ "$ref": "#/$defs/op_copy_file" },
{ "$ref": "#/$defs/op_merge_dir" },
{ "$ref": "#/$defs/op_merge_json" },
{ "$ref": "#/$defs/op_run_command" }
]
},
@@ -91,6 +92,18 @@
"description": { "type": "string" }
}
},
"op_merge_json": {
"type": "object",
"additionalProperties": false,
"required": ["type", "source", "target"],
"properties": {
"type": { "const": "merge_json" },
"source": { "type": "string", "minLength": 1 },
"target": { "type": "string", "minLength": 1 },
"merge_key": { "type": "string" },
"description": { "type": "string" }
}
},
"op_run_command": {
"type": "object",
"additionalProperties": false,

View File

@@ -11,13 +11,13 @@ A freshly designed lightweight development workflow with no legacy baggage, focu
AskUserQuestion (requirements clarification)
Codex analysis (extract key points and tasks)
codeagent analysis (plan mode + UI auto-detection)
develop-doc-generator (create dev doc)
dev-plan-generator (create dev doc)
Codex concurrent development (25 tasks)
codeagent concurrent development (25 tasks, backend split)
Codex testing & verification (≥90% coverage)
codeagent testing & verification (≥90% coverage)
Done (generate summary)
```
@@ -29,23 +29,27 @@ Done (generate summary)
- No scoring system, no complex logic
- 23 rounds of Q&A until the requirement is clear
### 2. Codex Analysis
- Call codex to analyze the request
### 2. codeagent Analysis & UI Detection
- Call codeagent to analyze the request in plan mode style
- Extract: core functions, technical points, task list (25 items)
- Output a structured analysis
- UI auto-detection: needs UI work when task involves style assets (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component files (.tsx, .jsx, .vue); output yes/no plus evidence
### 3. Generate Dev Doc
- Call the **develop-doc-generator** agent
- Call the **dev-plan-generator** agent
- Produce a single `dev-plan.md`
- Append a dedicated UI task when Step 2 marks `needs_ui: true`
- Include: task breakdown, file scope, dependencies, test commands
### 4. Concurrent Development
- Work from the task list in dev-plan.md
- Use codeagent per task with explicit backend selection:
- Backend/API/DB tasks → `--backend codex` (default)
- UI/style/component tasks → `--backend gemini` (enforced)
- Independent tasks → run in parallel
- Conflicting tasks → run serially
### 5. Testing & Verification
- Each codex task:
- Each codeagent task:
- Implements the feature
- Writes tests
- Runs coverage
@@ -76,8 +80,14 @@ Only one file—minimal and clear.
### Tools
- **AskUserQuestion**: interactive requirement clarification
- **codex**: analysis, development, testing
- **develop-doc-generator**: generate dev doc (subagent, saves context)
- **codeagent skill**: analysis, development, testing; supports `--backend` for codex (default) or gemini (UI)
- **dev-plan-generator agent**: generate dev doc (subagent via Task tool, saves context)
## UI Auto-Detection & Backend Routing
- **UI detection standard**: style files (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component code (.tsx, .jsx, .vue) trigger `needs_ui: true`
- **Flow impact**: Step 2 auto-detects UI work; Step 3 appends a separate UI task in `dev-plan.md` when detected
- **Backend split**: backend/API tasks use codex backend (default); UI tasks force gemini backend
- **Implementation**: Orchestrator invokes codeagent skill with appropriate backend parameter per task type
## Key Features
@@ -94,11 +104,11 @@ Only one file—minimal and clear.
### ✅ Concurrency
- 25 tasks in parallel
- Auto-detect dependencies and conflicts
- Codex executes independently
- codeagent executes independently
### ✅ Quality Assurance
- Enforces 90% coverage
- Codex tests and verifies its own work
- codeagent tests and verifies its own work
- Automatic retry on failure
## Example
@@ -113,20 +123,21 @@ A: Email + password
Q: Should login be remembered?
A: Yes, use JWT token
# Step 2: Codex analysis
# Step 2: codeagent analysis
Output:
- Core: email/password login + JWT auth
- Task 1: Backend API
- Task 2: Password hashing
- Task 3: Frontend form
UI detection: needs_ui = true (tailwindcss classes in frontend form)
# Step 3: Generate doc
dev-plan.md generated ✓
dev-plan.md generated with backend + UI tasks
# Step 4-5: Concurrent development
[task-1] Backend API → tests → 92% ✓
[task-2] Password hashing → tests → 95% ✓
[task-3] Frontend form → tests → 91% ✓
# Step 4-5: Concurrent development (backend codex, UI gemini)
[task-1] Backend API (codex) → tests → 92% ✓
[task-2] Password hashing (codex) → tests → 95% ✓
[task-3] Frontend form (gemini) → tests → 91% ✓
```
## Directory Structure
@@ -135,9 +146,9 @@ dev-plan.md generated ✓
dev-workflow/
├── README.md # This doc
├── commands/
│ └── dev.md # Workflow definition
│ └── dev.md # /dev workflow orchestrator definition
└── agents/
└── develop-doc-generator.md # Doc generator
└── dev-plan-generator.md # Dev plan document generator agent
```
Minimal structure, only three files.
@@ -155,7 +166,7 @@ Minimal structure, only three files.
1. **KISS**: keep it simple
2. **Disposable**: no persistent config
3. **Quality first**: enforce 90% coverage
4. **Concurrency first**: leverage codex
4. **Concurrency first**: leverage codeagent
5. **No legacy baggage**: clean-slate design
---

View File

@@ -12,7 +12,7 @@ You are a specialized Development Plan Document Generator. Your sole responsibil
You receive context from an orchestrator including:
- Feature requirements description
- Codex analysis results (feature highlights, task decomposition)
- codeagent analysis results (feature highlights, task decomposition, UI detection flag)
- Feature name (in kebab-case format)
Your output is a single file: `./.claude/specs/{feature_name}/dev-plan.md`
@@ -67,7 +67,7 @@ Your output is a single file: `./.claude/specs/{feature_name}/dev-plan.md`
## Your Workflow
1. **Analyze Input**: Review the requirements description and Codex analysis results
1. **Analyze Input**: Review the requirements description and codeagent analysis results (including `needs_ui` flag if present)
2. **Identify Tasks**: Break down the feature into 2-5 logical, independent tasks
3. **Determine Dependencies**: Map out which tasks depend on others (minimize dependencies)
4. **Specify Testing**: For each task, define the exact test command and coverage requirements

View File

@@ -1,5 +1,5 @@
---
description: Extreme lightweight end-to-end development workflow with requirements clarification, parallel codex execution, and mandatory 90% test coverage
description: Extreme lightweight end-to-end development workflow with requirements clarification, parallel codeagent execution, and mandatory 90% test coverage
---
@@ -8,7 +8,7 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
**Core Responsibilities**
- Orchestrate a streamlined 6-step development workflow:
1. Requirement clarification through targeted questioning
2. Technical analysis using Codex
2. Technical analysis using codeagent
3. Development documentation generation
4. Parallel development execution
5. Coverage validation (≥90% requirement)
@@ -20,9 +20,9 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
- Focus questions on functional boundaries, inputs/outputs, constraints, testing, and required unit-test coverage levels
- Iterate 2-3 rounds until clear; rely on judgment; keep questions concise
- **Step 2: Codex Deep Analysis (Plan Mode Style)**
- **Step 2: codeagent Deep Analysis (Plan Mode Style)**
Use Codex Skill to perform deep analysis. Codex should operate in "plan mode" style:
Use codeagent Skill to perform deep analysis. codeagent should operate in "plan mode" style and must include UI detection:
**When Deep Analysis is Needed** (any condition triggers):
- Multiple valid approaches exist (e.g., Redis vs in-memory vs file-based caching)
@@ -30,7 +30,11 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
- Large-scale changes touching many files or systems
- Unclear scope requiring exploration first
**What Codex Does in Analysis Mode**:
**UI Detection Requirements**:
- During analysis, output whether the task needs UI work (yes/no) and the evidence
- UI criteria: presence of style assets (.css, .scss, styled-components, CSS modules, tailwindcss) OR frontend component files (.tsx, .jsx, .vue)
**What codeagent Does in Analysis Mode**:
1. **Explore Codebase**: Use Glob, Grep, Read to understand structure, patterns, architecture
2. **Identify Existing Patterns**: Find how similar features are implemented, reuse conventions
3. **Evaluate Options**: When multiple approaches exist, list trade-offs (complexity, performance, security, maintainability)
@@ -53,6 +57,10 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
## Task Breakdown
[2-5 tasks with: ID, description, file scope, dependencies, test command]
## UI Determination
needs_ui: [true/false]
evidence: [files and reasoning tied to style + component criteria]
```
**Skip Deep Analysis When**:
@@ -62,24 +70,37 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
- **Step 3: Generate Development Documentation**
- invoke agent dev-plan-generator
- When creating `dev-plan.md`, append a dedicated UI task if Step 2 marked `needs_ui: true`
- Output a brief summary of dev-plan.md:
- Number of tasks and their IDs
- File scope for each task
- Dependencies between tasks
- Test commands
- Use AskUserQuestion to confirm with user:
- Question: "Proceed with this development plan?"
- Question: "Proceed with this development plan?" (if UI work is detected, state that UI tasks will use the gemini backend)
- Options: "Confirm and execute" / "Need adjustments"
- If user chooses "Need adjustments", return to Step 1 or Step 2 based on feedback
- **Step 4: Parallel Development Execution**
- For each task in `dev-plan.md`, invoke Codex with this brief:
```
- For each task in `dev-plan.md`, invoke codeagent skill with task brief in HEREDOC format:
```bash
# Backend task (use codex backend - default)
codeagent-wrapper --backend codex - <<'EOF'
Task: [task-id]
Reference: @.claude/specs/{feature_name}/dev-plan.md
Scope: [task file scope]
Test: [test command]
Deliverables: code + unit tests + coverage ≥90% + coverage summary
EOF
# UI task (use gemini backend - enforced)
codeagent-wrapper --backend gemini - <<'EOF'
Task: [task-id]
Reference: @.claude/specs/{feature_name}/dev-plan.md
Scope: [task file scope]
Test: [test command]
Deliverables: code + unit tests + coverage ≥90% + coverage summary
EOF
```
- Execute independent tasks concurrently; serialize conflicting ones; track coverage reports
@@ -92,7 +113,7 @@ You are the /dev Workflow Orchestrator, an expert development workflow manager s
- Provide completed task list, coverage per task, key file changes
**Error Handling**
- Codex failure: retry once, then log and continue
- codeagent failure: retry once, then log and continue
- Insufficient coverage: request more tests (max 2 rounds)
- Dependency conflicts: serialize automatically

407
docs/CODEAGENT-WRAPPER.md Normal file
View File

@@ -0,0 +1,407 @@
# Codeagent-Wrapper User Guide
Multi-backend AI code execution wrapper supporting Codex, Claude, and Gemini.
## Overview
`codeagent-wrapper` is a Go-based CLI tool that provides a unified interface to multiple AI coding backends. It handles:
- Multi-backend execution (Codex, Claude, Gemini)
- JSON stream parsing and output formatting
- Session management and resumption
- Parallel task execution with dependency resolution
- Timeout handling and signal forwarding
## Installation
```bash
# Clone repository
git clone https://github.com/cexll/myclaude.git
cd myclaude
# Install via install.py (includes binary compilation)
python3 install.py --module dev
# Or manual installation
cd codeagent-wrapper
go build -o ~/.claude/bin/codeagent-wrapper
```
## Quick Start
### Basic Usage
```bash
# Simple task (default: codex backend)
codeagent-wrapper "explain @src/main.go"
# With backend selection
codeagent-wrapper --backend claude "refactor @utils.ts"
# With HEREDOC (recommended for complex tasks)
codeagent-wrapper --backend gemini - <<'EOF'
Implement user authentication:
- JWT tokens
- Password hashing with bcrypt
- Session management
EOF
```
### Backend Selection
| Backend | Command | Best For |
|---------|---------|----------|
| **Codex** | `--backend codex` | General code tasks (default) |
| **Claude** | `--backend claude` | Complex reasoning, architecture |
| **Gemini** | `--backend gemini` | Fast iteration, prototyping |
## Core Features
### 1. Multi-Backend Support
```bash
# Codex (default)
codeagent-wrapper "add logging to @app.js"
# Claude for architecture decisions
codeagent-wrapper --backend claude - <<'EOF'
Design a microservices architecture for e-commerce:
- Service boundaries
- Communication patterns
- Data consistency strategy
EOF
# Gemini for quick prototypes
codeagent-wrapper --backend gemini "create React component for user profile"
```
### 2. File References with @ Syntax
```bash
# Single file
codeagent-wrapper "optimize @src/utils.ts"
# Multiple files
codeagent-wrapper "refactor @src/auth.ts and @src/middleware.ts"
# Entire directory
codeagent-wrapper "analyze @src for security issues"
```
### 3. Session Management
```bash
# First task
codeagent-wrapper "add validation to user form"
# Output includes: SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14
# Resume session
codeagent-wrapper resume 019a7247-ac9d-71f3-89e2-a823dbd8fd14 - <<'EOF'
Now add error messages for each validation rule
EOF
```
### 4. Parallel Execution
Execute multiple tasks concurrently with dependency management:
```bash
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
```
**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 |
## Troubleshooting
**Backend not found:**
```bash
# Ensure backend CLI is installed
which codex
which claude
which gemini
# Check PATH
echo $PATH
```
**Timeout too short:**
```bash
# Increase timeout to 4 hours
CODEX_TIMEOUT=14400000 codeagent-wrapper "complex task"
```
**Session ID not found:**
```bash
# List recent sessions (backend-specific)
codex history
# Ensure session ID is copied correctly
codeagent-wrapper resume <session_id> "continue task"
```
**Parallel tasks not running:**
```bash
# Check task format
# Ensure ---TASK--- and ---CONTENT--- delimiters are correct
# Verify task IDs are unique
# Check dependencies reference existing task IDs
```
## Integration with Claude Code
Use via the `codeagent` skill:
```bash
# In Claude Code conversation
User: Use codeagent to implement authentication
# Claude will execute:
codeagent-wrapper --backend codex - <<'EOF'
implement JWT authentication in @src/auth
EOF
```
## Performance Tips
1. **Use parallel execution** for independent tasks
2. **Choose the right backend** for the task type
3. **Keep working directory specific** to reduce context
4. **Resume sessions** for multi-step workflows
5. **Use @ syntax** to minimize file content in prompts
## Best Practices
1. **HEREDOC for complex tasks** - Avoid shell escaping nightmares
2. **Descriptive task IDs** - Use `<feature>_<timestamp>` format
3. **Absolute paths** - Avoid relative path confusion
4. **Session resumption** - Continue conversations with context
5. **Timeout tuning** - Set appropriate timeouts for task complexity
## Examples
### Example 1: Code Review
```bash
codeagent-wrapper --backend claude - <<'EOF'
Review @src/payment/stripe.ts for:
1. Security issues (API key handling, input validation)
2. Error handling (network failures, API errors)
3. Edge cases (duplicate charges, partial refunds)
4. Code quality (naming, structure, comments)
EOF
```
### Example 2: Refactoring
```bash
codeagent-wrapper --backend codex - <<'EOF'
Refactor @src/utils:
- Extract duplicate code into helpers
- Add TypeScript types
- Improve function naming
- Add JSDoc comments
EOF
```
### Example 3: Full-Stack Feature
```bash
codeagent-wrapper --parallel <<'EOF'
---TASK---
id: api_1701234567
workdir: /project/backend
---CONTENT---
implement /api/notifications endpoints with WebSocket support
---TASK---
id: ui_1701234568
workdir: /project/frontend
dependencies: api_1701234567
---CONTENT---
build Notifications component with real-time updates
---TASK---
id: tests_1701234569
workdir: /project
dependencies: api_1701234567, ui_1701234568
---CONTENT---
add E2E tests for notification flow
EOF
```
## Further Reading
- [Codex CLI Documentation](https://codex.docs)
- [Claude CLI Documentation](https://claude.ai/docs)
- [Gemini CLI Documentation](https://ai.google.dev/docs)
- [Architecture Overview](./architecture.md)

197
docs/HOOKS.md Normal file
View File

@@ -0,0 +1,197 @@
# Claude Code Hooks Guide
Hooks are shell scripts or commands that execute in response to Claude Code events.
## Available Hook Types
### 1. UserPromptSubmit
Runs after user submits a prompt, before Claude processes it.
**Use cases:**
- Auto-activate skills based on keywords
- Add context injection
- Log user requests
### 2. PostToolUse
Runs after Claude uses a tool.
**Use cases:**
- Validate tool outputs
- Run additional checks (linting, formatting)
- Log tool usage
### 3. Stop
Runs when Claude Code session ends.
**Use cases:**
- Cleanup temporary files
- Generate session reports
- Commit changes automatically
## Configuration
Hooks are configured in `.claude/settings.json`:
```json
{
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/hooks/skill-activation-prompt.sh"
}
]
}
],
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/hooks/post-tool-check.sh"
}
]
}
]
}
}
```
## Creating Custom Hooks
### Example: Pre-Commit Hook
**File:** `hooks/pre-commit.sh`
```bash
#!/bin/bash
set -e
# Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
# Run tests on Go files
GO_FILES=$(echo "$STAGED_FILES" | grep '\.go$' || true)
if [ -n "$GO_FILES" ]; then
go test ./... -short || exit 1
fi
# Validate JSON files
JSON_FILES=$(echo "$STAGED_FILES" | grep '\.json$' || true)
if [ -n "$JSON_FILES" ]; then
for file in $JSON_FILES; do
jq empty "$file" || exit 1
done
fi
echo "✅ Pre-commit checks passed"
```
**Register in settings.json:**
```json
{
"hooks": {
"PostToolUse": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/hooks/pre-commit.sh"
}
]
}
]
}
}
```
### Example: Auto-Format Hook
**File:** `hooks/auto-format.sh`
```bash
#!/bin/bash
# Format Go files
find . -name "*.go" -exec gofmt -w {} \;
# Format JSON files
find . -name "*.json" -exec jq --indent 2 . {} \; -exec mv {} {}.tmp \; -exec mv {}.tmp {} \;
echo "✅ Files formatted"
```
## Environment Variables
Hooks have access to:
- `$CLAUDE_PROJECT_DIR` - Project root directory
- `$PWD` - Current working directory
- All shell environment variables
## Best Practices
1. **Keep hooks fast** - Slow hooks block Claude Code
2. **Handle errors gracefully** - Return non-zero on failure
3. **Use absolute paths** - Reference `$CLAUDE_PROJECT_DIR`
4. **Make scripts executable** - `chmod +x hooks/script.sh`
5. **Test independently** - Run hooks manually first
6. **Document behavior** - Add comments explaining logic
## Debugging Hooks
Enable verbose logging:
```bash
# Add to your hook
set -x # Print commands
set -e # Exit on error
```
Test manually:
```bash
cd /path/to/project
./hooks/your-hook.sh
echo $? # Check exit code
```
## Built-in Hooks
This repository includes:
| Hook | File | Purpose |
|------|------|---------|
| Skill Activation | `skill-activation-prompt.sh` | Auto-suggest skills |
| Pre-commit | `pre-commit.sh` | Code quality checks |
## Disabling Hooks
Remove hook configuration from `.claude/settings.json` or set empty array:
```json
{
"hooks": {
"UserPromptSubmit": []
}
}
```
## Troubleshooting
**Hook not running?**
- Check `.claude/settings.json` syntax
- Verify script is executable: `ls -l hooks/`
- Check script path is correct
**Hook failing silently?**
- Add `set -e` to script
- Check exit codes: `echo $?`
- Add logging: `echo "debug" >> /tmp/hook.log`
## Further Reading
- [Claude Code Hooks Documentation](https://docs.anthropic.com/claude-code/hooks)
- [Bash Scripting Guide](https://www.gnu.org/software/bash/manual/)

12
hooks/hooks-config.json Normal file
View File

@@ -0,0 +1,12 @@
{
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "$CLAUDE_PROJECT_DIR/hooks/skill-activation-prompt.sh"
}
]
}
]
}

60
hooks/pre-commit.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/bin/bash
# Example pre-commit hook
# This hook runs before git commit to validate code quality
set -e
# Get staged files
STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
if [ -z "$STAGED_FILES" ]; then
echo "No files to validate"
exit 0
fi
echo "Running pre-commit checks..."
# Check Go files
GO_FILES=$(echo "$STAGED_FILES" | grep '\.go$' || true)
if [ -n "$GO_FILES" ]; then
echo "Checking Go files..."
# Format check
gofmt -l $GO_FILES | while read -r file; do
if [ -n "$file" ]; then
echo "$file needs formatting (run: gofmt -w $file)"
exit 1
fi
done
# Run tests
if command -v go &> /dev/null; then
echo "Running go tests..."
go test ./... -short || {
echo "❌ Tests failed"
exit 1
}
fi
fi
# Check JSON files
JSON_FILES=$(echo "$STAGED_FILES" | grep '\.json$' || true)
if [ -n "$JSON_FILES" ]; then
echo "Validating JSON files..."
for file in $JSON_FILES; do
if ! jq empty "$file" 2>/dev/null; then
echo "❌ Invalid JSON: $file"
exit 1
fi
done
fi
# Check Markdown files
MD_FILES=$(echo "$STAGED_FILES" | grep '\.md$' || true)
if [ -n "$MD_FILES" ]; then
echo "Checking markdown files..."
# Add markdown linting if needed
fi
echo "✅ All pre-commit checks passed"
exit 0

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env node
const fs = require("fs");
const path = require("path");
function readInput() {
const raw = fs.readFileSync(0, "utf8").trim();
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (_err) {
return {};
}
}
function extractPrompt(payload) {
return (
payload.prompt ||
payload.text ||
payload.userPrompt ||
(payload.data && payload.data.prompt) ||
""
).toString();
}
function loadRules() {
const rulesPath = path.resolve(__dirname, "../skills/skill-rules.json");
try {
const file = fs.readFileSync(rulesPath, "utf8");
return JSON.parse(file);
} catch (_err) {
return { skills: {} };
}
}
function matchSkill(prompt, rule, skillName) {
const triggers = (rule && rule.promptTriggers) || {};
const keywords = [...(triggers.keywords || []), skillName].filter(Boolean);
const patterns = triggers.intentPatterns || [];
const promptLower = prompt.toLowerCase();
const keyword = keywords.find((k) => promptLower.includes(k.toLowerCase()));
if (keyword) {
return `命中关键词 "${keyword}"`;
}
for (const pattern of patterns) {
try {
if (new RegExp(pattern, "i").test(prompt)) {
return `命中模式 /${pattern}/`;
}
} catch (_err) {
continue;
}
}
return null;
}
function main() {
const payload = readInput();
const prompt = extractPrompt(payload);
if (!prompt.trim()) {
console.log(JSON.stringify({ suggestedSkills: [] }, null, 2));
return;
}
const rules = loadRules();
const suggestions = [];
for (const [name, rule] of Object.entries(rules.skills || {})) {
const matchReason = matchSkill(prompt, rule, name);
if (matchReason) {
suggestions.push({
skill: name,
enforcement: rule.enforcement || "suggest",
priority: rule.priority || "normal",
reason: matchReason
});
}
}
console.log(JSON.stringify({ suggestedSkills: suggestions }, null, 2));
}
main();

View File

@@ -0,0 +1,12 @@
#!/usr/bin/env bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCRIPT="$SCRIPT_DIR/skill-activation-prompt.js"
if command -v node >/dev/null 2>&1; then
node "$SCRIPT" "$@" || true
else
echo '{"suggestedSkills":[],"meta":{"warning":"node not found"}}'
fi
exit 0

77
hooks/test-skill-activation.sh Executable file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Simple test runner for skill-activation-prompt hook.
# Each case feeds JSON to the hook and validates suggested skills.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK_SCRIPT="$SCRIPT_DIR/skill-activation-prompt.sh"
parse_skills() {
node -e 'const data = JSON.parse(require("fs").readFileSync(0, "utf8")); const skills = (data.suggestedSkills || []).map(s => s.skill); console.log(skills.join(" "));'
}
run_case() {
local name="$1"
local input="$2"
shift 2
local expected=("$@")
local output skills
output="$("$HOOK_SCRIPT" <<<"$input")"
skills="$(printf "%s" "$output" | parse_skills)"
local pass=0
if [[ ${#expected[@]} -eq 1 && ${expected[0]} == "none" ]]; then
[[ -z "$skills" ]] && pass=1
else
pass=1
for need in "${expected[@]}"; do
if [[ " $skills " != *" $need "* ]]; then
pass=0
break
fi
done
fi
if [[ $pass -eq 1 ]]; then
echo "PASS: $name"
else
echo "FAIL: $name"
echo " input: $input"
echo " expected skills: ${expected[*]}"
echo " actual skills: ${skills:-<empty>}"
return 1
fi
}
main() {
local status=0
run_case "keyword 'issue' => gh-workflow" \
'{"prompt":"Please open an issue for this bug"}' \
"gh-workflow" || status=1
run_case "keyword 'codex' => codex" \
'{"prompt":"codex please handle this change"}' \
"codex" || status=1
run_case "no matching keywords => none" \
'{"prompt":"Just saying hello"}' \
"none" || status=1
run_case "multiple keywords => codex & gh-workflow" \
'{"prompt":"codex refactor then open an issue"}' \
"codex" "gh-workflow" || status=1
if [[ $status -eq 0 ]]; then
echo "All tests passed."
else
echo "Some tests failed."
fi
exit "$status"
}
main "$@"

View File

@@ -9,13 +9,13 @@ set "OS=windows"
call :detect_arch
if errorlevel 1 goto :fail
set "BINARY_NAME=codex-wrapper-%OS%-%ARCH%.exe"
set "BINARY_NAME=codeagent-wrapper-%OS%-%ARCH%.exe"
set "URL=https://github.com/%REPO%/releases/%VERSION%/download/%BINARY_NAME%"
set "TEMP_FILE=%TEMP%\codex-wrapper-%ARCH%-%RANDOM%.exe"
set "TEMP_FILE=%TEMP%\codeagent-wrapper-%ARCH%-%RANDOM%.exe"
set "DEST_DIR=%USERPROFILE%\bin"
set "DEST=%DEST_DIR%\codex-wrapper.exe"
set "DEST=%DEST_DIR%\codeagent-wrapper.exe"
echo Downloading codex-wrapper for %ARCH% ...
echo Downloading codeagent-wrapper for %ARCH% ...
echo %URL%
call :download
if errorlevel 1 goto :fail
@@ -43,7 +43,7 @@ if errorlevel 1 (
)
echo.
echo codex-wrapper installed successfully at:
echo codeagent-wrapper installed successfully at:
echo %DEST%
rem Automatically ensure %USERPROFILE%\bin is in the USER (HKCU) PATH

View File

@@ -60,6 +60,11 @@ def parse_args(argv: Optional[Iterable[str]] = None) -> argparse.Namespace:
action="store_true",
help="Force overwrite existing files",
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable verbose output to terminal",
)
return parser.parse_args(argv)
@@ -124,6 +129,7 @@ def resolve_paths(config: Dict[str, Any], args: argparse.Namespace) -> Dict[str,
"status_file": install_dir / "installed_modules.json",
"config_dir": config_dir,
"force": bool(getattr(args, "force", False)),
"verbose": bool(getattr(args, "verbose", False)),
"applied_paths": [],
"status_backup": None,
}
@@ -131,12 +137,13 @@ def resolve_paths(config: Dict[str, Any], args: argparse.Namespace) -> Dict[str,
def list_modules(config: Dict[str, Any]) -> None:
print("Available Modules:")
print(f"{'Name':<15} {'Enabled':<8} Description")
print(f"{'Name':<15} {'Default':<8} Description")
print("-" * 60)
for name, cfg in config.get("modules", {}).items():
enabled = "" if cfg.get("enabled", False) else ""
default = "" if cfg.get("enabled", False) else ""
desc = cfg.get("description", "")
print(f"{name:<15} {enabled:<8} {desc}")
print(f"{name:<15} {default:<8} {desc}")
print("\n✓ = installed by default when no --module specified")
def select_modules(config: Dict[str, Any], module_arg: Optional[str]) -> Dict[str, Any]:
@@ -183,6 +190,8 @@ def execute_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[
op_copy_file(op, ctx)
elif op_type == "merge_dir":
op_merge_dir(op, ctx)
elif op_type == "merge_json":
op_merge_json(op, ctx)
elif op_type == "run_command":
op_run_command(op, ctx)
else:
@@ -279,6 +288,51 @@ def op_copy_file(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
write_log({"level": "INFO", "message": f"Copied file {src} -> {dst}"}, ctx)
def op_merge_json(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
"""Merge JSON from source into target, supporting nested key paths."""
src = _source_path(op, ctx)
dst = _target_path(op, ctx)
merge_key = op.get("merge_key")
if not src.exists():
raise FileNotFoundError(f"Source JSON not found: {src}")
src_data = _load_json(src)
dst.parent.mkdir(parents=True, exist_ok=True)
if dst.exists():
dst_data = _load_json(dst)
else:
dst_data = {}
_record_created(dst, ctx)
if merge_key:
# Merge into specific key
keys = merge_key.split(".")
target = dst_data
for key in keys[:-1]:
target = target.setdefault(key, {})
last_key = keys[-1]
if isinstance(src_data, dict) and isinstance(target.get(last_key), dict):
# Deep merge for dicts
target[last_key] = {**target.get(last_key, {}), **src_data}
else:
target[last_key] = src_data
else:
# Merge at root level
if isinstance(src_data, dict) and isinstance(dst_data, dict):
dst_data = {**dst_data, **src_data}
else:
dst_data = src_data
with dst.open("w", encoding="utf-8") as fh:
json.dump(dst_data, fh, indent=2, ensure_ascii=False)
fh.write("\n")
write_log({"level": "INFO", "message": f"Merged JSON {src} -> {dst} (key: {merge_key or 'root'})"}, ctx)
def op_run_command(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
env = os.environ.copy()
for key, value in op.get("env", {}).items():
@@ -287,28 +341,56 @@ def op_run_command(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
command = op.get("command", "")
if sys.platform == "win32" and command.strip() == "bash install.sh":
command = "cmd /c install.bat"
result = subprocess.run(
# Stream output in real-time while capturing for logging
process = subprocess.Popen(
command,
shell=True,
cwd=ctx["config_dir"],
env=env,
capture_output=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
stdout_lines: List[str] = []
stderr_lines: List[str] = []
# Read stdout and stderr in real-time
import selectors
sel = selectors.DefaultSelector()
sel.register(process.stdout, selectors.EVENT_READ) # type: ignore[arg-type]
sel.register(process.stderr, selectors.EVENT_READ) # type: ignore[arg-type]
while process.poll() is None or sel.get_map():
for key, _ in sel.select(timeout=0.1):
line = key.fileobj.readline() # type: ignore[union-attr]
if not line:
sel.unregister(key.fileobj)
continue
if key.fileobj == process.stdout:
stdout_lines.append(line)
print(line, end="", flush=True)
else:
stderr_lines.append(line)
print(line, end="", file=sys.stderr, flush=True)
sel.close()
process.wait()
write_log(
{
"level": "INFO",
"message": f"Command: {command}",
"stdout": result.stdout,
"stderr": result.stderr,
"returncode": result.returncode,
"stdout": "".join(stdout_lines),
"stderr": "".join(stderr_lines),
"returncode": process.returncode,
},
ctx,
)
if result.returncode != 0:
raise RuntimeError(f"Command failed with code {result.returncode}: {command}")
if process.returncode != 0:
raise RuntimeError(f"Command failed with code {process.returncode}: {command}")
def write_log(entry: Dict[str, Any], ctx: Dict[str, Any]) -> None:
@@ -325,6 +407,17 @@ def write_log(entry: Dict[str, Any], ctx: Dict[str, Any]) -> None:
if key in entry and entry[key] not in (None, ""):
fh.write(f" {key}: {entry[key]}\n")
# Terminal output when verbose
if ctx.get("verbose"):
prefix = {"INFO": " ", "WARNING": "⚠️ ", "ERROR": ""}.get(level, "")
print(f"{prefix}[{level}] {message}")
if entry.get("stdout"):
print(f" stdout: {entry['stdout'][:500]}")
if entry.get("stderr"):
print(f" stderr: {entry['stderr'][:500]}", file=sys.stderr)
if entry.get("returncode") is not None:
print(f" returncode: {entry['returncode']}")
def write_status(results: List[Dict[str, Any]], ctx: Dict[str, Any]) -> None:
status = {
@@ -400,11 +493,17 @@ def main(argv: Optional[Iterable[str]] = None) -> int:
prepare_status_backup(ctx)
total = len(modules)
print(f"Installing {total} module(s) to {ctx['install_dir']}...")
results: List[Dict[str, Any]] = []
for name, cfg in modules.items():
for idx, (name, cfg) in enumerate(modules.items(), 1):
print(f"[{idx}/{total}] Installing module: {name}...")
try:
results.append(execute_module(name, cfg, ctx))
except Exception: # noqa: BLE001
print(f"{name} installed successfully")
except Exception as exc: # noqa: BLE001
print(f"{name} failed: {exc}", file=sys.stderr)
if not args.force:
rollback(ctx)
return 1
@@ -420,6 +519,19 @@ def main(argv: Optional[Iterable[str]] = None) -> int:
break
write_status(results, ctx)
# Summary
success = sum(1 for r in results if r.get("status") == "success")
failed = len(results) - success
if failed == 0:
print(f"\n✓ Installation complete: {success} module(s) installed")
print(f" Log file: {ctx['log_file']}")
else:
print(f"\n⚠ Installation finished with errors: {success} success, {failed} failed")
print(f" Check log file for details: {ctx['log_file']}")
if not args.force:
return 1
return 0

View File

@@ -22,22 +22,22 @@ esac
# Build download URL
REPO="cexll/myclaude"
VERSION="latest"
BINARY_NAME="codex-wrapper-${OS}-${ARCH}"
BINARY_NAME="codeagent-wrapper-${OS}-${ARCH}"
URL="https://github.com/${REPO}/releases/${VERSION}/download/${BINARY_NAME}"
echo "Downloading codex-wrapper from ${URL}..."
if ! curl -fsSL "$URL" -o /tmp/codex-wrapper; then
echo "Downloading codeagent-wrapper from ${URL}..."
if ! curl -fsSL "$URL" -o /tmp/codeagent-wrapper; then
echo "ERROR: failed to download binary" >&2
exit 1
fi
mkdir -p "$HOME/bin"
mv /tmp/codex-wrapper "$HOME/bin/codex-wrapper"
chmod +x "$HOME/bin/codex-wrapper"
mv /tmp/codeagent-wrapper "$HOME/bin/codeagent-wrapper"
chmod +x "$HOME/bin/codeagent-wrapper"
if "$HOME/bin/codex-wrapper" --version >/dev/null 2>&1; then
echo "codex-wrapper installed successfully to ~/bin/codex-wrapper"
if "$HOME/bin/codeagent-wrapper" --version >/dev/null 2>&1; then
echo "codeagent-wrapper installed successfully to ~/bin/codeagent-wrapper"
else
echo "ERROR: installation verification failed" >&2
exit 1

View File

@@ -1,9 +1,9 @@
You are Linus Torvalds. Obey the following priority stack (highest first) and refuse conflicts by citing the higher rule:
1. Role + Safety: stay in character, enforce KISS/YAGNI/never break userspace, think in English, respond to the user in Chinese, stay technical.
2. Workflow Contract: Claude Code performs intake, context gathering, planning, and verification only; every edit or test must be executed via Codex skill (`codex`).
2. Workflow Contract: Claude Code performs intake, context gathering, planning, and verification only; every edit or test must be executed via Codeagent skill (`codeagent`).
3. Tooling & Safety Rules:
- Capture errors, retry once if transient, document fallbacks.
4. Context Blocks & Persistence: honor `<context_gathering>`, `<exploration>`, `<persistence>`, `<tool_preambles>`, and `<self_reflection>` exactly as written below.
4. Context Blocks & Persistence: honor `<context_gathering>`, `<exploration>`, `<persistence>`, `<tool_preambles>`, `<self_reflection>`, and `<testing>` exactly as written below.
5. Quality Rubrics: follow the code-editing rules, implementation checklist, and communication standards; keep outputs concise.
6. Reporting: summarize in Chinese, include file paths with line numbers, list risks and next steps when relevant.
@@ -21,8 +21,8 @@ Trigger conditions:
- User explicitly requests deep analysis
Process:
- Requirements: Break the ask into explicit requirements, unclear areas, and hidden assumptions.
- Scope mapping: Identify codebase regions, files, functions, or libraries likely involved. If unknown, perform targeted parallel searches NOW before planning. For complex codebases or deep call chains, delegate scope analysis to Codex skill.
- Dependencies: Identify relevant frameworks, APIs, config files, data formats, and versioning concerns. When dependencies involve complex framework internals or multi-layer interactions, delegate to Codex skill for analysis.
- Scope mapping: Identify codebase regions, files, functions, or libraries likely involved. If unknown, perform targeted parallel searches NOW before planning. For complex codebases or deep call chains, delegate scope analysis to Codeagent skill.
- Dependencies: Identify relevant frameworks, APIs, config files, data formats, and versioning concerns. When dependencies involve complex framework internals or multi-layer interactions, delegate to Codeagent skill for analysis.
- Ambiguity resolution: Choose the most probable interpretation based on repo context, conventions, and dependency docs. Document assumptions explicitly.
- Output contract: Define exact deliverables (files changed, expected outputs, API responses, CLI behavior, tests passing, etc.).
In plan mode: Invest extra effort here—this phase determines plan quality and depth.
@@ -42,6 +42,23 @@ Before any tool call, restate the user goal and outline the current plan. While
Construct a private rubric with at least five categories (maintainability, performance, security, style, documentation, backward compatibility). Evaluate the work before finalizing; revisit the implementation if any category misses the bar.
</self_reflection>
<testing>
Unit tests must be requirement-driven, not implementation-driven.
Coverage requirements:
- Happy path: all normal use cases from requirements
- Edge cases: boundary values, empty inputs, max limits
- Error handling: invalid inputs, failure scenarios, permission errors
- State transitions: if stateful, cover all valid state changes
Process:
1. Extract test scenarios from requirements BEFORE writing tests
2. Each requirement maps to ≥1 test case
3. A single test file is insufficient—enumerate all scenarios explicitly
4. Run tests to verify; if any scenario fails, fix before declaring done
Reject "wrote a unit test" as completion—demand "all requirement scenarios covered and passing."
</testing>
<output_verbosity>
- Small changes (≤10 lines): 2-5 sentences, no headings, at most 1 short code snippet
- Medium changes: ≤6 bullet points, at most 2 code snippets (≤8 lines each)

171
skills/codeagent/SKILL.md Normal file
View File

@@ -0,0 +1,171 @@
---
name: codeagent
description: Execute codeagent-wrapper for multi-backend AI code tasks. Supports Codex, Claude, and Gemini backends with file references (@syntax) and structured output.
---
# Codeagent Wrapper Integration
## Overview
Execute codeagent-wrapper commands with pluggable AI backends (Codex, Claude, Gemini). Supports file references via `@` syntax, parallel task execution with backend selection, and configurable security controls.
## When to Use
- Complex code analysis requiring deep understanding
- Large-scale refactoring across multiple files
- Automated code generation with backend selection
## Usage
**HEREDOC syntax** (recommended):
```bash
codeagent-wrapper - [working_dir] <<'EOF'
<task content here>
EOF
```
**With backend selection**:
```bash
codeagent-wrapper --backend claude - <<'EOF'
<task content here>
EOF
```
**Simple tasks**:
```bash
codeagent-wrapper "simple task" [working_dir]
codeagent-wrapper --backend gemini "simple task"
```
## Backends
| Backend | Command | Description |
|---------|---------|-------------|
| codex | `--backend codex` | OpenAI Codex (default) |
| claude | `--backend claude` | Anthropic Claude |
| gemini | `--backend gemini` | Google Gemini |
## Parameters
- `task` (required): Task description, supports `@file` references
- `working_dir` (optional): Working directory (default: current)
- `--backend` (optional): Select AI backend (codex/claude/gemini, default: codex)
- **Note**: Claude backend defaults to `--dangerously-skip-permissions` for automation compatibility
## Return Format
```
Agent response text here...
---
SESSION_ID: 019a7247-ac9d-71f3-89e2-a823dbd8fd14
```
## Resume Session
```bash
# Resume with default backend
codeagent-wrapper resume <session_id> - <<'EOF'
<follow-up task>
EOF
# Resume with specific backend
codeagent-wrapper --backend claude resume <session_id> - <<'EOF'
<follow-up task>
EOF
```
## Parallel Execution
**With global backend**:
```bash
codeagent-wrapper --parallel --backend claude <<'EOF'
---TASK---
id: task1
workdir: /path/to/dir
---CONTENT---
task content
---TASK---
id: task2
dependencies: task1
---CONTENT---
dependent task
EOF
```
**With per-task backend**:
```bash
codeagent-wrapper --parallel <<'EOF'
---TASK---
id: task1
backend: codex
workdir: /path/to/dir
---CONTENT---
analyze code structure
---TASK---
id: task2
backend: claude
dependencies: task1
---CONTENT---
design architecture based on analysis
---TASK---
id: task3
backend: gemini
dependencies: task2
---CONTENT---
generate implementation code
EOF
```
**Concurrency Control**:
Set `CODEAGENT_MAX_PARALLEL_WORKERS` to limit concurrent tasks (default: unlimited).
## Environment Variables
- `CODEX_TIMEOUT`: Override timeout in milliseconds (default: 7200000 = 2 hours)
- `CODEAGENT_SKIP_PERMISSIONS`: Control permission checks
- For **Claude** backend: Set to `true`/`1` to **disable** `--dangerously-skip-permissions` (default: enabled)
- For **Codex/Gemini** backends: Set to `true`/`1` to enable permission skipping (default: disabled)
- `CODEAGENT_MAX_PARALLEL_WORKERS`: Limit concurrent tasks in parallel mode (default: unlimited, recommended: 8)
## Invocation Pattern
**Single Task**:
```
Bash tool parameters:
- command: codeagent-wrapper --backend <backend> - [working_dir] <<'EOF'
<task content>
EOF
- timeout: 7200000
- description: <brief description>
```
**Parallel Tasks**:
```
Bash tool parameters:
- command: codeagent-wrapper --parallel --backend <backend> <<'EOF'
---TASK---
id: task_id
backend: <backend> # Optional, overrides global
workdir: /path
dependencies: dep1, dep2
---CONTENT---
task content
EOF
- timeout: 7200000
- description: <brief description>
```
## Security Best Practices
- **Claude Backend**: Defaults to `--dangerously-skip-permissions` for automation workflows
- To enforce permission checks with Claude: Set `CODEAGENT_SKIP_PERMISSIONS=true`
- **Codex/Gemini Backends**: Permission checks enabled by default
- **Concurrency Limits**: Set `CODEAGENT_MAX_PARALLEL_WORKERS` in production to prevent resource exhaustion
- **Automation Context**: This wrapper is designed for AI-driven automation where permission prompts would block execution
## Recent Updates
- Multi-backend support for all modes (workdir, resume, parallel)
- Security controls with configurable permission checks
- Concurrency limits with worker pool and fail-fast cancellation

View File

@@ -0,0 +1,362 @@
---
name: product-requirements
description: Interactive Product Owner skill for requirements gathering, analysis, and PRD generation. Triggers when users request product requirements, feature specification, PRD creation, or need help understanding and documenting project requirements. Uses quality scoring and iterative dialogue to ensure comprehensive requirements before generating professional PRD documents.
---
# Product Requirements Skill
## Overview
Transform user requirements into professional Product Requirements Documents (PRDs) through interactive dialogue, quality scoring, and iterative refinement. Act as Sarah, a meticulous Product Owner who ensures requirements are clear, testable, and actionable before documentation.
## Core Identity
- **Role**: Technical Product Owner & Requirements Specialist
- **Approach**: Systematic, quality-driven, user-focused
- **Method**: Quality scoring (100-point scale) with 90+ threshold for PRD generation
- **Output**: Professional yet concise PRDs saved to `docs/{feature-name}-prd.md`
## Interactive Process
### Step 1: Initial Understanding & Context Gathering
Greet as Sarah and immediately gather project context:
```
"Hi! I'm Sarah, your Product Owner. I'll help define clear requirements for your feature.
Let me first understand your project context..."
```
**Context gathering actions:**
1. Read project README, package.json/pyproject.toml in parallel
2. Understand tech stack, existing architecture, and conventions
3. Present initial interpretation of the user's request within project context
4. Ask: "Is this understanding correct? What would you like to add?"
**Early stop**: Once you can articulate the feature request clearly within the project's context, proceed to quality assessment.
### Step 2: Quality Assessment (100-Point System)
Evaluate requirements across five dimensions:
#### Scoring Breakdown:
**Business Value & Goals (30 points)**
- 10 pts: Clear problem statement and business need
- 10 pts: Measurable success metrics and KPIs
- 10 pts: Expected outcomes and ROI justification
**Functional Requirements (25 points)**
- 10 pts: Complete user stories with acceptance criteria
- 10 pts: Clear feature descriptions and workflows
- 5 pts: Edge cases and error handling defined
**User Experience (20 points)**
- 8 pts: Well-defined user personas
- 7 pts: User journey and interaction flows
- 5 pts: UI/UX preferences and constraints
**Technical Constraints (15 points)**
- 5 pts: Performance requirements
- 5 pts: Security and compliance needs
- 5 pts: Integration requirements
**Scope & Priorities (10 points)**
- 5 pts: Clear MVP definition
- 3 pts: Phased delivery plan
- 2 pts: Priority rankings
**Display format:**
```
📊 Requirements Quality Score: [TOTAL]/100
Breakdown:
- Business Value & Goals: [X]/30
- Functional Requirements: [X]/25
- User Experience: [X]/20
- Technical Constraints: [X]/15
- Scope & Priorities: [X]/10
[If < 90]: Let me ask targeted questions to improve clarity...
[If ≥ 90]: Excellent! Ready to generate PRD.
```
### Step 3: Targeted Clarification
**If score < 90**, use `AskUserQuestion` tool to clarify gaps. Focus on the lowest-scoring area first.
**Question categories by dimension:**
**Business Value (if <24/30):**
- "What specific business problem are we solving?"
- "How will we measure success?"
- "What happens if we don't build this?"
**Functional Requirements (if <20/25):**
- "Can you walk me through the main user workflows?"
- "What should happen when [specific edge case]?"
- "What are the must-have vs. nice-to-have features?"
**User Experience (if <16/20):**
- "Who are the primary users?"
- "What are their goals and pain points?"
- "Can you describe the ideal user experience?"
**Technical Constraints (if <12/15):**
- "What performance expectations do you have?"
- "Are there security or compliance requirements?"
- "What systems need to integrate with this?"
**Scope & Priorities (if <8/10):**
- "What's the minimum viable product (MVP)?"
- "How should we phase the delivery?"
- "What are the top 3 priorities?"
**Ask 2-3 questions at a time** using `AskUserQuestion` tool. Don't overwhelm.
### Step 4: Iterative Refinement
After each user response:
1. Update understanding
2. Recalculate quality score
3. Show progress: "Great! That improved [area] from X to Y."
4. Continue until 90+ threshold met
### Step 5: Final Confirmation & PRD Generation
When score ≥ 90:
```
"Excellent! Here's the final PRD summary:
[2-3 sentence executive summary]
📊 Final Quality Score: [SCORE]/100
Generating professional PRD at docs/{feature-name}-prd.md..."
```
Generate PRD using template below, then confirm:
```
"✅ PRD saved to docs/{feature-name}-prd.md
Review the document and let me know if any adjustments are needed."
```
## PRD Template (Streamlined Professional Version)
Save to: `docs/{feature-name}-prd.md`
```markdown
# Product Requirements Document: [Feature Name]
**Version**: 1.0
**Date**: [YYYY-MM-DD]
**Author**: Sarah (Product Owner)
**Quality Score**: [SCORE]/100
---
## Executive Summary
[2-3 paragraphs covering: what problem this solves, who it helps, and expected impact. Include business context and why this feature matters now.]
---
## Problem Statement
**Current Situation**: [Describe current pain points or limitations]
**Proposed Solution**: [High-level description of the feature]
**Business Impact**: [Quantifiable or qualitative expected outcomes]
---
## Success Metrics
**Primary KPIs:**
- [Metric 1]: [Target value and measurement method]
- [Metric 2]: [Target value and measurement method]
- [Metric 3]: [Target value and measurement method]
**Validation**: [How and when we'll measure these metrics]
---
## User Personas
### Primary: [Persona Name]
- **Role**: [User type]
- **Goals**: [What they want to achieve]
- **Pain Points**: [Current frustrations]
- **Technical Level**: [Novice/Intermediate/Advanced]
[Add secondary persona if relevant]
---
## User Stories & Acceptance Criteria
### Story 1: [Story Title]
**As a** [persona]
**I want to** [action]
**So that** [benefit]
**Acceptance Criteria:**
- [ ] [Specific, testable criterion]
- [ ] [Another criterion covering happy path]
- [ ] [Edge case or error handling criterion]
### Story 2: [Story Title]
[Repeat structure]
[Continue for all core user stories - typically 3-5 for MVP]
---
## Functional Requirements
### Core Features
**Feature 1: [Name]**
- Description: [Clear explanation of functionality]
- User flow: [Step-by-step interaction]
- Edge cases: [What happens when...]
- Error handling: [How system responds to failures]
**Feature 2: [Name]**
[Repeat structure]
### Out of Scope
- [Explicitly list what's NOT included in this release]
- [Helps prevent scope creep]
---
## Technical Constraints
### Performance
- [Response time requirements: e.g., "API calls < 200ms"]
- [Scalability: e.g., "Support 10k concurrent users"]
### Security
- [Authentication/authorization requirements]
- [Data protection and privacy considerations]
- [Compliance requirements: GDPR, SOC2, etc.]
### Integration
- **[System 1]**: [Integration details and dependencies]
- **[System 2]**: [Integration details]
### Technology Stack
- [Required frameworks, libraries, or platforms]
- [Compatibility requirements: browsers, devices, OS]
- [Infrastructure constraints: cloud provider, database, etc.]
---
## MVP Scope & Phasing
### Phase 1: MVP (Required for Initial Launch)
- [Core feature 1]
- [Core feature 2]
- [Core feature 3]
**MVP Definition**: [What's the minimum that delivers value?]
### Phase 2: Enhancements (Post-Launch)
- [Enhancement 1]
- [Enhancement 2]
### Future Considerations
- [Potential future feature 1]
- [Potential future feature 2]
---
## Risk Assessment
| Risk | Probability | Impact | Mitigation Strategy |
|------|------------|--------|---------------------|
| [Risk 1: e.g., API rate limits] | High/Med/Low | High/Med/Low | [Specific mitigation plan] |
| [Risk 2: e.g., User adoption] | High/Med/Low | High/Med/Low | [Mitigation plan] |
| [Risk 3: e.g., Technical debt] | High/Med/Low | High/Med/Low | [Mitigation plan] |
---
## Dependencies & Blockers
**Dependencies:**
- [Dependency 1]: [Description and owner]
- [Dependency 2]: [Description]
**Known Blockers:**
- [Blocker 1]: [Description and resolution plan]
---
## Appendix
### Glossary
- **[Term]**: [Definition]
- **[Term]**: [Definition]
### References
- [Link to design mockups]
- [Related documentation]
- [Technical specs or API docs]
---
*This PRD was created through interactive requirements gathering with quality scoring to ensure comprehensive coverage of business, functional, UX, and technical dimensions.*
```
## Communication Guidelines
### Tone
- Professional yet approachable
- Clear, jargon-free language
- Collaborative and respectful
### Show Progress
- Celebrate improvements: "Great! That really clarifies things."
- Acknowledge complexity: "This is a complex requirement, let's break it down."
- Be transparent: "I need more information about X to ensure quality."
### Handle Uncertainty
- If user is unsure: "That's okay, let's explore some options..."
- For assumptions: "I'll assume X based on typical patterns, but we can adjust."
## Important Behaviors
### DO:
- Start with greeting and context gathering
- Show quality scores transparently after assessment
- Use `AskUserQuestion` tool for clarification (2-3 questions max per round)
- Iterate until 90+ quality threshold
- Generate PRD with proper feature name in filename
- Maintain focus on actionable, testable requirements
### DON'T:
- Skip context gathering phase
- Accept vague requirements (iterate to 90+)
- Overwhelm with too many questions at once
- Proceed without quality threshold
- Make assumptions without validation
- Use overly technical jargon
## Success Criteria
- ✅ Achieve 90+ quality score through systematic dialogue
- ✅ Create concise, actionable PRD (not bloated documentation)
- ✅ Save to `docs/{feature-name}-prd.md` with proper naming
- ✅ Enable smooth handoff to development phase
- ✅ Maintain positive, collaborative user engagement
---
**Remember**: Think in English, respond to user in Chinese. Quality over speed—iterate until requirements are truly clear.

View File

@@ -0,0 +1,497 @@
---
name: prototype-prompt-generator
description: This skill should be used when users need to generate detailed, structured prompts for creating UI/UX prototypes. Trigger when users request help with "create a prototype prompt", "design a mobile app", "generate UI specifications", or need comprehensive design documentation for web/mobile applications. Works with multiple design systems including WeChat Work, iOS Native, Material Design, and Ant Design Mobile.
---
# Prototype Prompt Generator
## Overview
Generate comprehensive, production-ready prompts for UI/UX prototype creation. Transform user requirements into detailed technical specifications that include design systems, color palettes, component specifications, layout structures, and implementation guidelines. Output prompts are structured for optimal consumption by AI tools or human developers building HTML/CSS/React prototypes.
## Workflow
### Step 1: Gather Requirements
Begin by collecting essential information from the user. Ask targeted questions to understand:
**Application Type & Purpose:**
- What kind of application? (e.g., enterprise tool, e-commerce, social media, dashboard)
- Who are the target users?
- What are the primary use cases and workflows?
**Platform & Context:**
- Target platform: iOS, Android, Web, WeChat Mini Program, or cross-platform?
- Device: Mobile phone, tablet, desktop, or responsive?
- Viewport dimensions if known (e.g., 375px for iPhone, 1200px for desktop)
**Design Preferences:**
- Design style: WeChat Work, iOS Native, Material Design, Ant Design Mobile, or custom?
- Brand colors or visual preferences?
- Any design references or inspiration?
**Feature Requirements:**
- Key pages and features needed
- Navigation structure (tabs, drawer, stack navigation)
- Data to display (metrics, lists, forms, media)
- User interactions (tap, swipe, long-press, etc.)
**Content & Data:**
- Actual content to display (realistic text, numbers, names)
- Empty states, error states, loading states
- Any specific business logic or rules
**Technical Constraints:**
- Framework preference: Plain HTML, React, Vue, or framework-agnostic?
- CSS approach: Tailwind CSS, CSS Modules, styled-components?
- Image assets: Real images, placeholders, or specific sources?
- CDN dependencies or version requirements?
**Ask questions incrementally** (2-3 at a time) to avoid overwhelming the user. Many details can be inferred from context or filled with sensible defaults.
### Step 2: Select Design System
Based on the gathered requirements, choose the appropriate design system from `references/design-systems.md`:
**WeChat Work Style:**
- **When to use**: Chinese enterprise applications, work management tools, B2B platforms, internal business systems
- **Characteristics**: Simple and professional, tech blue primary color, clear information hierarchy
- **Key audience**: Chinese business users, corporate environments
**iOS Native Style:**
- **When to use**: iOS-specific apps, Apple ecosystem integration, apps targeting iPhone/iPad users
- **Characteristics**: Minimalist, spacious layouts, San Francisco font, system colors
- **Key audience**: Apple users, consumer apps, content-focused applications
**Material Design Style:**
- **When to use**: Android-first apps, Google ecosystem integration, cross-platform with Material UI
- **Characteristics**: Bold graphics, elevation system, ripple effects, Roboto font
- **Key audience**: Android users, Google services, developer tools
**Ant Design Mobile Style:**
- **When to use**: Enterprise mobile applications with complex data entry and forms
- **Characteristics**: Efficiency-oriented, consistent components, suitable for business applications
- **Key audience**: Business users, enterprise mobile apps, data-heavy interfaces
**If the user hasn't specified a design system**, recommend one based on:
- Geographic location: Chinese users → WeChat Work, Western users → iOS/Material
- Platform: iOS → iOS Native, Android → Material Design
- Application type: Enterprise B2B → WeChat Work or Ant Design, Consumer app → iOS or Material
Load the complete design system specifications from `references/design-systems.md` to ensure accurate color codes, component dimensions, and interaction patterns.
### Step 3: Structure the Prompt
Using the template from `references/prompt-structure.md`, construct a comprehensive prompt with these sections:
**1. Role Definition**
Define expertise relevant to the prototype:
```
# Role
You are a world-class UI/UX engineer and frontend developer, specializing in [specific domain] using [technologies].
```
**2. Task Description**
State clearly what to build and the design style:
```
# Task
Create a [type] prototype for [application description].
Design style must strictly follow [design system], with core keywords: [3-5 key attributes].
```
**3. Tech Stack Specifications**
List all technologies, frameworks, and resources:
- File structure (single HTML, multi-page, component-based)
- Framework and version (e.g., Tailwind CSS CDN)
- Device simulation (viewport size, device chrome)
- Asset sources (Unsplash, Pexels, real images)
- Icon libraries (FontAwesome, Material Icons)
- Custom configuration (Tailwind config, theme variables)
**4. Visual Design Requirements**
Provide detailed specifications:
**(a) Color Palette:**
Include all colors with hex codes:
- Background colors (main, section, card)
- Primary and accent colors with usage
- Status colors (success, warning, error)
- Text colors (title, body, secondary, disabled)
- UI element colors (borders, dividers)
**(b) UI Style Characteristics:**
Specify for each component type:
- Cards: background, radius, shadow, border, padding
- Buttons: variants (primary, secondary, ghost), dimensions, states
- Icons: style, sizes, colors, containers
- List items: layout, height, divider style, active state
- Shadows: type and usage
**(c) Layout Structure:**
Describe each major section:
- Top navigation bar: height, title style, icons, background
- Content areas: grids, cards, lists, spacing
- Quick access areas: icon grids, layouts
- Data display cards: metrics, layout, styling
- Feature lists: structure, icons, interactions
- Bottom tab bar: height, tabs, active/inactive states, badges
**(d) Specific Page Content:**
Provide actual content, not placeholders:
- Real page titles and section headings
- Actual data points (numbers, names, dates)
- Feature names and descriptions
- Button labels and link text
- Sample list items with realistic content
**5. Implementation Details**
Cover technical specifics:
- Page width and centering approach
- Layout systems (Flexbox, Grid, or both)
- Fixed/sticky positioning for navigation
- Spacing scale (margins, padding, gaps)
- Typography (font family, sizes, weights)
- Interactive states (hover, active, focus, disabled)
- Icon sources and usage
- Border and divider styling
**6. Tailwind Configuration**
If using Tailwind CSS, provide custom config:
```javascript
tailwind.config = {
theme: {
extend: {
colors: {
'brand-primary': '#3478F6',
// ... all custom colors
}
}
}
}
```
**7. Content Structure & Hierarchy**
Visualize the page structure as a tree:
```
Page Name
├─ Section 1
│ ├─ Element 1
│ └─ Element 2
├─ Section 2
│ ├─ Subsection A
│ │ ├─ Item 1
│ │ └─ Item 2
│ └─ Subsection B
└─ Section 3
```
**8. Special Requirements**
Highlight unique considerations:
- Design system-specific guidelines
- Primary color application scenarios
- Interaction details (tap feedback, animations, gestures)
- Accessibility requirements (contrast, touch targets, ARIA)
- Performance considerations (image optimization, lazy loading)
**9. Output Format**
Specify the exact deliverable:
```
# Output Format
Please output complete [file type] code, ensuring:
1. [Requirement 1]
2. [Requirement 2]
...
The output should be production-ready and viewable at [viewport] on [device].
```
### Step 4: Populate with Specifics
Replace all template placeholders with concrete values:
**Replace vague terms with precise specifications:**
- ❌ "Use blue colors" → ✅ "Primary: #3478F6 (tech blue), Link: #576B95 (link blue)"
- ❌ "Make buttons rounded" → ✅ "Border radius: 4px (Tailwind: rounded)"
- ❌ "Add some spacing" → ✅ "Card spacing: 12px, page margins: 16px"
- ❌ "Display user info" → ✅ "Show username (15px bold), email (13px gray), avatar (48px circle)"
**Use real content, not placeholders:**
- ❌ "Lorem ipsum dolor sit amet" → ✅ "Customer Total: 14, Today's New Customers: 1, Today's Revenue: ¥0.00"
- ❌ "[Company Name]" → ✅ "Acme Insurance Co."
- ❌ "Feature 1, Feature 2, Feature 3" → ✅ "Customer Contact, Customer Moments, Customer Groups"
**Specify all measurements:**
- Component heights (44px, 50px, 64px)
- Font sizes (13px, 15px, 16px, 18px)
- Spacing values (8px, 12px, 16px, 24px)
- Icon sizes (24px, 32px, 48px)
- Border radius (4px, 8px, 10px)
**Define all states:**
- Normal: base colors and styles
- Hover: if applicable (desktop)
- Active/Pressed: opacity or background changes
- Disabled: grayed out with reduced opacity
- Selected: highlight color (often primary brand color)
**Include all colors:**
Every color mentioned must have a hex code. Reference the chosen design system from `references/design-systems.md` for accurate values.
### Step 5: Quality Assurance
Before presenting the final prompt, verify against the checklist in `references/prompt-structure.md`:
**Completeness Check:**
- [ ] Role clearly defined with relevant expertise
- [ ] Task explicitly states what to build and design style
- [ ] All tech stack components listed with versions/CDNs
- [ ] Complete color palette with hex codes for all colors
- [ ] All UI components specified with exact dimensions and styles
- [ ] Page layout fully described with precise measurements
- [ ] Actual, realistic content provided (no placeholders like "Lorem Ipsum" or "[Name]")
- [ ] Implementation details cover all technical requirements
- [ ] Tailwind config included if using Tailwind CSS
- [ ] Content hierarchy visualized as a tree structure
- [ ] Special requirements and interactions documented
- [ ] Output format clearly defined with all deliverables
**Clarity Check:**
- [ ] No ambiguous terms or vague descriptions (e.g., "some padding", "nice colors")
- [ ] All measurements specified with units (px, rem, %, vh, etc.)
- [ ] All colors defined with hex codes (e.g., #3478F6, not just "blue")
- [ ] Component states described (normal, hover, active, disabled, selected)
- [ ] Layout relationships clear (parent-child, spacing, alignment, z-index)
**Specificity Check:**
- [ ] Design system explicitly named (WeChat Work, iOS Native, Material Design, etc.)
- [ ] Viewport dimensions provided (e.g., 375px × 812px for iPhone)
- [ ] Typography scale defined (sizes, weights, line heights)
- [ ] Interactive behaviors documented with timing if animated
- [ ] Edge cases considered (long text overflow, empty states, loading, errors)
**Realism Check:**
- [ ] Real content examples, not Latin placeholder text
- [ ] Authentic data points (realistic numbers, names, dates, amounts)
- [ ] Practical feature set (not overengineered or underspecified)
- [ ] Appropriate complexity for the stated use case
**Technical Accuracy Check:**
- [ ] Valid Tailwind class names (if using Tailwind)
- [ ] Correct CDN links with versions (e.g., https://cdn.tailwindcss.com)
- [ ] Proper HTML structure implied (semantic elements, hierarchy)
- [ ] Feasible layout techniques (Flexbox/Grid patterns that work)
- [ ] Accessible markup considerations (touch targets ≥44px, color contrast)
If any checks fail, refine the prompt before proceeding.
### Step 6: Present and Iterate
**Present the generated prompt to the user** with a brief explanation:
- What design system was selected and why
- Key design decisions made
- Any assumptions or defaults applied
- How to use the prompt (copy and provide to another AI tool or developer)
**Offer refinement options:**
- "Would you like to adjust any colors or spacing?"
- "Should we add more pages or features?"
- "Do you want to change the design system?"
- "Any specific interactions or animations to emphasize?"
**Iterate based on feedback:**
If the user requests changes:
1. Update the relevant sections of the prompt
2. Maintain consistency across all sections
3. Re-verify against the quality checklist
4. Present the updated prompt
**Save or Export:**
Offer to save the prompt to a file:
- Markdown file for documentation
- Text file for easy copying
- Include as a code block for immediate use
## Best Practices
**1. Default to High Quality:**
Even if the user provides minimal requirements, generate a comprehensive prompt. It's easier to remove details than to add them later. Include:
- Complete color palettes (8-12 colors minimum)
- All common UI components (buttons, cards, lists, inputs)
- Multiple component states (normal, active, disabled)
- Responsive considerations
- Accessibility basics (contrast, touch targets)
**2. Use Design System Defaults Intelligently:**
When user requirements are vague:
- Apply the full design system consistently
- Use standard component dimensions from the design system
- Follow established patterns (e.g., WeChat Work's 64px list items)
- Include typical interaction patterns for the platform
**3. Prioritize Clarity Over Brevity:**
Longer, detailed prompts produce better prototypes than short, vague ones. Include:
- Exact hex codes instead of color names
- Precise measurements instead of relative terms
- Specific component layouts instead of general descriptions
- Actual content instead of placeholder text
**4. Think Mobile-First:**
For mobile applications, always consider:
- Safe areas (iOS notch, Android gesture bar)
- Touch target sizes (minimum 44px × 44px)
- Thumb-reachable zones (bottom navigation over top)
- Portrait orientation primarily (landscape as secondary)
- One-handed operation where possible
**5. Balance Flexibility and Specificity:**
- Be specific about core design elements (colors, typography, key components)
- Allow flexibility in implementation details (exact animation timing, minor spacing adjustments)
- Specify "must-haves" clearly, mark "nice-to-haves" as optional
**6. Consider the Full User Journey:**
Include specifications for:
- Entry points (splash screen, onboarding if applicable)
- Primary workflows (happy path through key features)
- Edge cases (empty states, error states, loading states)
- Exit points (logout, back navigation, completion states)
**7. Provide Context, Not Just Specs:**
Explain the "why" behind design decisions:
- "Tech blue (#3478F6) for trust and professionalism in enterprise context"
- "64px list item height for comfortable thumb tapping on mobile"
- "Fixed bottom tab bar for quick access to primary features"
**8. Validate Technical Feasibility:**
Before finalizing the prompt:
- Ensure CSS/Tailwind classes can achieve the described design
- Verify that layout patterns work with the stated grid/flexbox approach
- Confirm that the specified viewport can accommodate all content
- Check that CDN links and versions are correct and available
**9. Make It Actionable:**
The prompt should enable immediate implementation:
- Include all necessary CDN links and imports
- Provide complete Tailwind config (no "...add more as needed")
- Specify file structure and organization
- Define clear deliverables (HTML file, React components, etc.)
**10. Anticipate Questions:**
Address common uncertainties in the prompt:
- Font fallbacks (e.g., "sans-serif" system font stack)
- Image dimensions and aspect ratios
- Icon usage (when to use FontAwesome vs SVG vs emoji)
- Z-index layering (what's on top)
- Overflow behavior (scroll, truncate, wrap)
## Common Patterns
### Pattern 1: Enterprise Work Dashboard (WeChat Work Style)
**Typical Structure:**
- Top navigation bar (44px, title + search/menu icons)
- Quick access grid (4-column icon grid)
- Data summary cards (key metrics in horizontal layout)
- Feature list (icon + text rows, 64px height each)
- Bottom tab bar (5 tabs, 50px height)
**Key Elements:**
- Tech blue (#3478F6) for primary actions and active states
- White cards with subtle shadows on light gray background
- 48px icons with rounded-lg containers
- Right arrow indicators for navigation
### Pattern 2: iOS Consumer App (iOS Native Style)
**Typical Structure:**
- Large title navigation bar (96px when expanded)
- Card-based content sections
- System standard lists (44px minimum row height)
- Tab bar with SF Symbols icons
**Key Elements:**
- System blue (#007AFF) for interactive elements
- Generous whitespace (20px margins, 16px padding)
- Subtle dividers with left inset
- Translucent blur effects on navigation
### Pattern 3: Android App (Material Design Style)
**Typical Structure:**
- Top app bar (56px on mobile, 64px on tablet)
- FAB (Floating Action Button) for primary action
- Card-based content with elevation
- Bottom navigation or navigation drawer
**Key Elements:**
- Bold primary color (#6200EE) with elevation shadows
- Ripple effects on tap
- 16dp grid system
- Material icons (24px)
### Pattern 4: Enterprise Form App (Ant Design Mobile)
**Typical Structure:**
- Simple navigation bar (45px)
- Form sections with grouped inputs
- List views with detailed information
- Fixed bottom action bar with primary button
**Key Elements:**
- Professional blue (#108EE9) for actions
- Dense information layout
- Clear form field labels and validation
- Breadcrumb or step indicators for multi-step flows
## Troubleshooting
**Issue: User requirements are too vague**
**Solution:** Ask focused questions, provide examples of similar apps, suggest design systems to choose from, or create a default prompt and offer iteration.
**Issue: User wants multiple design styles mixed**
**Solution:** Pick a primary design system for overall structure and consistency, then incorporate specific elements from other systems as accent features. Explain trade-offs.
**Issue: User specifies impossible or conflicting requirements**
**Solution:** Identify the conflict, explain why it's problematic (e.g., "64px icons won't fit in a 44px navigation bar"), suggest alternatives, and seek clarification.
**Issue: Too many features for one prompt**
**Solution:** Focus on the primary page/workflow first, generate that prompt, then create separate prompts for additional features. Maintain consistency across prompts.
**Issue: User lacks technical knowledge**
**Solution:** Avoid jargon, explain design decisions in plain language, provide visual descriptions instead of technical terms, and include helpful comments in the prompt.
**Issue: Prototype prompt doesn't produce good results**
**Solution:** Review against the quality checklist, ensure all colors have hex codes, verify all measurements are specified, add more specific content examples, check for ambiguous language.
## Resources
This skill includes reference documentation to support prompt generation:
### references/design-systems.md
Comprehensive specifications for major design systems:
- **WeChat Work Style**: Chinese enterprise applications
- **iOS Native Style**: Apple ecosystem apps
- **Material Design**: Google/Android apps
- **Ant Design Mobile**: Enterprise mobile apps
Each design system includes:
- Complete color palettes with hex codes
- Component specifications (dimensions, spacing, states)
- Typography scales (sizes, weights, line heights)
- Interaction patterns (animations, gestures, feedback)
- Layout guidelines (grids, spacing, safe areas)
- Code examples (Tailwind classes, CSS snippets)
**When to reference:** Always load this file when generating a prompt to ensure accurate design system specifications. Use it to populate color values, component dimensions, and interaction patterns.
### references/prompt-structure.md
Detailed template and guidelines for prompt construction:
- Standard prompt structure (9 sections)
- Template syntax with placeholders
- Examples for each section
- Quality checklist (completeness, clarity, specificity)
- Workflow guidance (requirements → prompt → iteration)
- Tips for effective prompts
- Common pitfalls to avoid
**When to reference:** Use this as the skeleton for every generated prompt. It ensures consistency and completeness across all prompts you create.
---
**Note:** This skill generates prompts for prototype creation—it does not create the prototypes themselves. The output is a comprehensive text prompt that can be provided to another AI tool, developer, or design tool to generate the actual HTML/CSS/React code.

View File

@@ -0,0 +1,388 @@
# Design Systems Reference
This document provides detailed specifications for common design systems used in mobile and web applications.
## 企业微信 (WeChat Work) Style
### Core Characteristics
- **Simplicity & Professionalism**: Clean interface with clear information hierarchy
- **Operational Clarity**: Clear action paths with explicit next steps
- **Tech Blue**: Primary color scheme emphasizing efficiency and trust
### Color Palette
```
Primary Colors:
- Tech Blue: #3478F6 (buttons, icons, emphasis)
- Link Blue: #576B95 (hyperlinks)
- Alert Red: #FA5151 (warnings, errors)
- Warning Orange: #FF976A (cautions)
Neutral Colors:
- Title Black: #191919 (headings)
- Text Gray: #333333 (body text)
- Light Text: #999999 (secondary text)
- Divider: #E5E5E5 (borders, separators)
- Background Area: #F7F8FA (section backgrounds)
- White: #FFFFFF (card backgrounds)
```
### Component Specifications
#### Cards
- Background: White (#FFFFFF)
- Border Radius: 8px (rounded-lg)
- Shadow: Subtle (shadow-sm)
- No thick borders
- Spacing: 12px between cards
#### Buttons
- Primary: Blue background (#3478F6), white text
- Height: 44px
- Border Radius: 4px (rounded)
- Active State: 90% opacity (active:opacity-90)
#### Icons
- Style: Rounded square background (rounded-lg) or pure icon
- Primary Icons: Tech Blue (#3478F6)
- Sizes: 24px / 32px / 48px
#### List Items
- Layout: Left icon/avatar (48px) + Title (15px bold) + Subtitle (13px gray) + Right arrow
- Height: 64px
- Divider: Left indent 64px (aligned with content)
- Active State: Gray background (active:bg-gray-50)
#### Navigation Bar (Top)
- Height: 44px
- Title: Centered, 16px bold, deep black
- Background: White with 1px bottom border (border-gray-200)
- Icons: Search and menu icons on right
#### Tab Bar (Bottom)
- Height: 50px
- Layout: Icon + Text (vertical)
- Selected: Tech Blue (#3478F6)
- Unselected: Gray
- Position: Fixed bottom (fixed bottom-0)
- Badge Support: Red circular badge with white text
### Typography
- System Default: Sans-serif
- Headings: font-semibold or font-bold
- Title: 16px bold
- Body: 15px
- Caption: 13px
- Helper Text: 13px gray
### Spacing & Layout
- Page Margins: 16px (left/right)
- Card Spacing: 12px
- Content Padding: 16px
- Mobile Width: 375px (max-w-[375px])
### Interaction Patterns
- Tap Feedback: Background changes to light gray (bg-gray-50)
- Button Press: Opacity reduces to 90%
- Card Elevation: Subtle shadow (shadow-sm)
- List Navigation: Right arrow indicator
---
## iOS Native Style
### Core Characteristics
- **Minimalism**: Clean, spacious layouts with generous whitespace
- **Clarity**: Clear visual hierarchy and focus on content
- **Depth**: Subtle shadows and layering to create depth
### Color Palette
```
System Colors:
- Blue: #007AFF (primary actions, links)
- Green: #34C759 (success, positive actions)
- Red: #FF3B30 (destructive actions, errors)
- Orange: #FF9500 (warnings)
- Yellow: #FFCC00 (cautions)
- Gray: #8E8E93 (secondary text)
Background Colors:
- System Background: #FFFFFF (light mode), #000000 (dark mode)
- Secondary Background: #F2F2F7 (light mode), #1C1C1E (dark mode)
- Tertiary Background: #FFFFFF (light mode), #2C2C2E (dark mode)
Text Colors:
- Primary: #000000 (light mode), #FFFFFF (dark mode)
- Secondary: #3C3C43 (60% opacity)
- Tertiary: #3C3C43 (30% opacity)
```
### Component Specifications
#### Navigation Bar
- Height: 44px (compact), 96px (large title)
- Title: 34px bold (large), 17px semibold (inline)
- Background: Translucent blur effect
- Buttons: Text or icon, 17px regular
#### Tab Bar
- Height: 49px
- Layout: Icon + Text (vertical, 10pt text)
- Selected: Blue (#007AFF)
- Unselected: Gray (#8E8E93)
- Position: Fixed bottom
- Blur Effect: Translucent background
#### List/Table View
- Cell Height: 44px minimum
- Layout: Left accessory + Title + Detail + Right accessory/chevron
- Separator: 0.5px hairline, inset from left
- Active State: Gray highlight (bg-gray-100)
#### Buttons
- Primary: Blue background (#007AFF), white text, 50px height, rounded corners (10px)
- Secondary: No background, blue text
- Destructive: Red text
- Corner Radius: 10px (rounded-lg)
#### Cards
- Border Radius: 10px (rounded-lg)
- Background: White (light mode), #1C1C1E (dark mode)
- Shadow: Subtle (0 1px 3px rgba(0,0,0,0.1))
- Padding: 16px
### Typography (San Francisco Font)
- Large Title: 34px bold
- Title 1: 28px regular
- Title 2: 22px regular
- Title 3: 20px regular
- Headline: 17px semibold
- Body: 17px regular
- Callout: 16px regular
- Subheadline: 15px regular
- Footnote: 13px regular
- Caption 1: 12px regular
- Caption 2: 11px regular
### Spacing
- Edge Margins: 16px (standard), 20px (large screens)
- Inter-Element Spacing: 8px / 16px / 24px
- Section Spacing: 32px
### Interaction Patterns
- Tap Feedback: Subtle highlight (no ripple effect)
- Swipe Gestures: Swipe to delete, swipe back navigation
- Pull to Refresh: System spinner at top
- Haptic Feedback: Light impact on selection
---
## Material Design (Google) Style
### Core Characteristics
- **Material Metaphor**: Physical surfaces and realistic motion
- **Bold Graphics**: Intentional use of color, imagery, typography
- **Motion**: Meaningful animations that provide feedback
### Color Palette
```
Primary Colors:
- Primary: #6200EE (brand color, main actions)
- Primary Variant: #3700B3 (darker shade)
- Secondary: #03DAC6 (accent color, floating actions)
- Secondary Variant: #018786 (darker accent)
Status Colors:
- Error: #B00020 (errors, alerts)
- Success: #4CAF50 (success states)
- Warning: #FF9800 (warnings)
- Info: #2196F3 (informational)
Background/Surface:
- Background: #FFFFFF (light), #121212 (dark)
- Surface: #FFFFFF (light), #121212 (dark)
- Surface Variant: #F5F5F5 (light), #1E1E1E (dark)
Text Colors:
- High Emphasis: #000000 (87% opacity)
- Medium Emphasis: #000000 (60% opacity)
- Disabled: #000000 (38% opacity)
```
### Component Specifications
#### App Bar (Top)
- Height: 56px (mobile), 64px (desktop)
- Title: 20px medium weight
- Background: Primary color or white
- Icons: 24px, white or primary color
- Elevation: 4dp
#### Bottom Navigation
- Height: 56px
- Layout: Icon (24px) + Label (12px)
- Active: Primary color with ripple effect
- Inactive: 60% opacity
- Elevation: 8dp
#### Cards
- Border Radius: 4px (rounded)
- Background: White (light), #1E1E1E (dark)
- Elevation: 1dp (resting), 8dp (raised)
- Padding: 16px
#### Buttons
- Contained: Primary color background, white text, 36px height, 4px radius
- Outlined: 1px border, primary color text, transparent background
- Text: No border/background, primary color text
- Corner Radius: 4px (rounded)
- Ripple Effect: Material ripple on tap
#### Lists
- Item Height: 48px (single-line), 64px (two-line), 88px (three-line)
- Left Icon: 24px (40px container)
- Right Icon: 24px
- Divider: 1px, 87% opacity
- Ripple Effect: On tap
#### Floating Action Button (FAB)
- Size: 56px (standard), 40px (mini)
- Shape: Circular
- Color: Secondary color
- Elevation: 6dp (resting), 12dp (pressed)
- Icon: 24px white
### Typography (Roboto Font)
- H1: 96px light
- H2: 60px light
- H3: 48px regular
- H4: 34px regular
- H5: 24px regular
- H6: 20px medium
- Subtitle 1: 16px regular
- Subtitle 2: 14px medium
- Body 1: 16px regular
- Body 2: 14px regular
- Button: 14px medium, uppercase
- Caption: 12px regular
- Overline: 10px regular, uppercase
### Spacing & Grid
- Base Unit: 8dp
- Layout Grid: 8dp increments
- Margins: 16dp (mobile), 24dp (tablet)
- Gutters: 16dp (mobile), 24dp (tablet)
- Touch Target: 48dp minimum
### Elevation System
- Level 0: 0dp (background)
- Level 1: 1dp (cards at rest)
- Level 2: 2dp (buttons at rest)
- Level 3: 3dp (refresh indicator)
- Level 4: 4dp (app bar)
- Level 6: 6dp (FAB at rest)
- Level 8: 8dp (bottom nav, menus)
- Level 12: 12dp (FAB pressed)
- Level 16: 16dp (nav drawer)
- Level 24: 24dp (dialog, picker)
### Interaction Patterns
- Ripple Effect: Circular expanding animation from tap point
- State Changes: Smooth color transitions (300ms)
- Entry/Exit Animations: Fade + scale/slide
- Touch Feedback: Immediate visual response
---
## Ant Design Mobile Style
### Core Characteristics
- **Enterprise-Grade**: Professional UI for business applications
- **Efficiency-Oriented**: Optimized for quick task completion
- **Consistent**: Unified design language across platforms
### Color Palette
```
Primary Colors:
- Brand Blue: #108EE9 (primary actions)
- Link Blue: #108EE9 (links, emphasis)
- Success: #00A854 (success states)
- Warning: #FFBF00 (warnings)
- Error: #F04134 (errors, destructive actions)
Neutral Colors:
- Heading: #000000 (85% opacity)
- Body Text: #000000 (65% opacity)
- Secondary Text: #000000 (45% opacity)
- Disabled: #000000 (25% opacity)
- Border: #E9E9E9 (borders, dividers)
- Background: #F5F5F5 (page background)
- White: #FFFFFF (component background)
```
### Component Specifications
#### Navigation Bar
- Height: 45px
- Title: 18px bold, centered
- Background: #108EE9 or white
- Icons: 22px
- Border Bottom: 1px (#E9E9E9)
#### Tab Bar
- Height: 50px
- Layout: Icon (22px) + Text (10px)
- Active: Brand blue (#108EE9)
- Inactive: #888888
- Badge: Red dot or number
#### List
- Item Height: 44px minimum
- Left Icon: 22px (44px container)
- Right Arrow: 16px
- Divider: 1px, inset 15px from left
- Active State: #DDDDDD background
#### Buttons
- Default: 47px height, 5px radius
- Primary: Blue background (#108EE9), white text
- Ghost: Transparent background, blue border and text
- Disabled: Gray with 60% opacity
#### Cards
- Border Radius: 2px (rounded-sm)
- Background: White
- Border: 1px (#E9E9E9) or none
- Shadow: Optional subtle shadow
- Padding: 15px
### Typography (System Default)
- Heading: 18px bold
- Subheading: 15px bold
- Body: 14px regular
- Caption: 12px regular
- Button: 16px medium
### Spacing
- Base Unit: 5px
- Standard Spacing: 15px
- Edge Margins: 15px
- Component Padding: 15px
---
## Usage Guidelines
When generating a prototype prompt, reference the appropriate design system based on user requirements:
1. **WeChat Work Style**: Use for Chinese enterprise applications, work management tools, B2B platforms
2. **iOS Native Style**: Use when user requests iOS-specific design or mentions Apple guidelines
3. **Material Design**: Use for Android-first apps, Google ecosystem apps, or when cross-platform Material UI is requested
4. **Ant Design Mobile**: Use for enterprise mobile applications with complex data and forms
For each design system, include:
- Complete color palette with hex codes
- Component specifications (dimensions, spacing, states)
- Typography scale (sizes, weights, line heights)
- Interaction patterns (tap feedback, animations)
- Accessibility considerations
- Code examples (Tailwind classes or CSS)

View File

@@ -0,0 +1,530 @@
# Prototype Prompt Structure Template
This document provides a comprehensive template for generating high-quality prototype prompts. Each section includes guidelines and examples.
---
## Standard Prompt Structure
A well-structured prototype prompt should contain the following sections in order:
### 1. Role Definition
Define the expertise and perspective Claude should adopt.
**Template:**
```
# Role
You are a world-class [domain] engineer specializing in [specific skills]. You excel at [key capabilities].
```
**Example:**
```
# Role
You are a world-class UI/UX engineer and frontend developer, specializing in creating professional, efficient enterprise-grade mobile application interfaces using Tailwind CSS.
```
---
### 2. Task Description
Clearly state what needs to be created, with emphasis on key quality attributes.
**Template:**
```
# Task
Create a [type of prototype] for [application description].
Design style must strictly follow [design system/style guide], with core keywords: [3-5 key attributes].
```
**Example:**
```
# Task
Create a high-fidelity prototype (HTML + Tailwind CSS) for an insurance agent work application.
Design style must strictly follow WeChat Work style, with core keywords: simple and professional, clear information hierarchy, explicit action paths, tech blue primary color.
```
---
### 3. Tech Stack Specifications
List all technologies, frameworks, and resources required.
**Template:**
```
# Tech Stack
- File Structure: [describe output file organization]
- Frameworks: [CSS frameworks, JS libraries, etc.]
- Device Simulation: [viewport size, device features]
- Asset Sources: [image sources, icon libraries]
- CDN Resources: [external dependencies]
- Custom Configuration: [theme extensions, variables]
```
**Example:**
```
# Tech Stack
- Each page outputs as an independent HTML file (e.g., home.html, discover.html)
- index.html is the entry point, using iframe to display all pages
- Simulate iPhone 16 Pro dimensions with rounded corners, including iOS status bar and bottom navigation
- Use real UI image assets (from Unsplash, Pexels, Apple UI resources), no placeholders
- Include Tailwind CSS (CDN: <script src="https://cdn.tailwindcss.com"></script>)
- Use FontAwesome (CDN) for icons
- Custom Tailwind config with brand colors: Tech Blue #3478F6, Link Blue #576B95, Alert Red #FA5151
```
---
### 4. Visual Design Requirements
Provide detailed specifications for visual design elements.
**Sections to include:**
1. **Color Palette**: Primary, secondary, neutral, and status colors with hex codes
2. **UI Style Characteristics**: Card design, buttons, icons, list items, shadows
3. **Layout Structure**: Viewport dimensions, navigation bars, content areas, tab bars
4. **Specific Page Content**: Actual text, data, and functional elements
**Template:**
```
# Visual Design Requirements
## 1. Color Palette
**Background Colors:**
- Main Background: [color name] [hex code]
- Section Background: [color name] [hex code]
**Primary Colors:**
- Main Color: [color name] [hex code] (usage: [buttons, icons, emphasis])
- Accent Color: [color name] [hex code] (usage: [links, secondary actions])
**Status Colors:**
- Success: [hex code]
- Warning: [hex code]
- Error: [hex code]
**Text Colors:**
- Title: [hex code]
- Body: [hex code]
- Secondary: [hex code]
**UI Elements:**
- Divider: [hex code]
- Border: [hex code]
## 2. UI Style Characteristics
**Card Design:**
- Background: [color]
- Border Radius: [size] ([Tailwind class])
- Shadow: [type] ([Tailwind class])
- Border: [style]
- Spacing: [measurements]
**Buttons:**
- Primary Button: [background] + [text color], height [px], radius [size] ([Tailwind class])
- Active State: [opacity/color] ([Tailwind class])
- Additional variants: [outline, ghost, text, etc.]
**Icons:**
- Style: [rounded/square/circular] ([Tailwind class])
- Primary Icon Color: [color]
- Sizes: [24px / 32px / 48px]
- Container: [if applicable]
**List Items:**
- Layout: [Left element] + [Title size/weight] + [Subtitle size/color] + [Right element]
- Height: [px]
- Divider: [position, style]
- Active State: [interaction feedback]
**Shadows:**
- Style: [subtle/prominent] ([Tailwind classes])
- Usage: [where shadows apply]
## 3. Layout Structure (Mobile View - [width]px)
**Top Navigation Bar ([height]px):**
- Title: [position, size, weight, color]
- Left/Right Icons: [elements]
- Background: [color]
- Border: [style and position]
**Content Sections:**
[Describe each major section with:]
- Section Name
- Layout Pattern: [grid/flex/list]
- Elements: [what it contains]
- Styling: [colors, spacing, borders]
**Quick Access Area:**
[If applicable]
- Grid: [columns x rows]
- Icon Style: [shape, size]
- Text: [size, position]
**Data Display Cards:**
[If applicable]
- Metrics: [list key data points]
- Number Display: [size, weight]
- Label Style: [size, color]
- Layout: [horizontal/vertical/grid]
**Feature List:**
[If applicable]
- Section Title: [size, color, alignment]
- List Items: [icon + text layout]
- Item Height: [px]
- Interaction: [tap feedback]
**Bottom TabBar ([height]px, Sticky):**
- Tab Count: [number]
- Tabs: [list tab names]
- Active State: [color, style]
- Inactive State: [color, style]
- Badges: [if applicable]
- Position: [fixed/sticky]
## 4. Specific Page Content
[Include actual content for the prototype:]
**Page Title:** [name]
**Section 1: [Name]**
- Element 1: [description]
- Element 2: [description]
...
**Section 2: [Name]**
- [Actual text and data to display]
**Bottom Navigation:**
- [List all tabs with states]
```
---
### 5. Implementation Details
Provide technical implementation guidelines.
**Template:**
```
# Implementation Details
- Page Width: [max-width] and [alignment]
- Layout System: [Flexbox/Grid/both]
- Navigation: [fixed/sticky positions]
- Spacing: [margins, padding, gaps]
- Typography: [font family, weights, sizes]
- Interactive States: [hover, active, focus effects]
- Icons: [source and usage]
- Borders: [colors and positions]
```
**Example:**
```
# Implementation Details
- Page width set to max-w-[375px] and centered to simulate phone screen
- Use Flexbox and Grid layout systems
- Top navigation bar: sticky top-0 z-10
- Bottom TabBar: fixed bottom-0 w-full
- Card spacing: 12px, page left/right margins: 16px
- Typography: System default sans-serif, titles use font-semibold or font-bold
- All clickable elements have tap feedback: active:opacity-90 or active:bg-gray-50
- Icons: FontAwesome or placeholder div + background color
- Dividers: border-gray-200 or border-gray-100
```
---
### 6. Tailwind Configuration
If using Tailwind CSS, provide custom configuration.
**Template:**
```
# Tailwind Config (in <script> tag)
```javascript
tailwind.config = {
theme: {
extend: {
colors: {
'brand-primary': '[hex]',
'brand-secondary': '[hex]',
// ... more colors
},
spacing: {
// custom spacing if needed
},
fontSize: {
// custom font sizes if needed
}
}
}
}
```
```
---
### 7. Content Structure & Hierarchy
Visualize the page structure and content hierarchy.
**Template:**
```
# Example Content & Structure
[Page Name]
├─ [Section 1 Name]
│ ├─ [Element 1]
│ ├─ [Element 2]
│ └─ [Element 3]
├─ [Section 2 Name]
│ ├─ [Subsection A]
│ │ ├─ [Item 1]
│ │ └─ [Item 2]
│ └─ [Subsection B]
└─ [Section 3 Name]
└─ [Elements]
```
**Example:**
```
# Example Content & Structure
Work Dashboard
├─ Quick Access (4-grid)
│ ├─ Manage Enterprise
│ ├─ Select Apps
│ ├─ Find Services
│ └─ Submit Requests
├─ Feature Card
│ └─ Customer Data Stats (14 customers, 1 new, ¥0.00)
├─ Common Features List
│ ├─ Customer Contact
│ ├─ Customer Moments
│ ├─ Customer Groups
│ ├─ WeChat Service
│ └─ Broadcast Assistant
└─ Bottom TabBar
├─ Messages
├─ Email
├─ Documents
├─ Workspace (current)
└─ Contacts (16)
```
---
### 8. Special Requirements
Highlight unique considerations or constraints.
**Template:**
```
# Special Requirements
- [Design System] Key Points:
- [Characteristic 1]
- [Characteristic 2]
- [Characteristic 3]
- [Primary Color] Application Scenarios:
- [Usage 1]
- [Usage 2]
- [Usage 3]
- Interaction Details:
- [Interaction 1]: [behavior]
- [Interaction 2]: [behavior]
- [Interaction 3]: [behavior]
- Accessibility:
- [Requirement 1]
- [Requirement 2]
- Performance:
- [Consideration 1]
- [Consideration 2]
```
---
### 9. Output Format
Specify the exact deliverable format.
**Template:**
```
# Output Format
Please output complete [file type] code, ensuring:
1. [Requirement 1]
2. [Requirement 2]
3. [Requirement 3]
...
The output should be production-ready and viewable at [viewport size] on [device type].
```
**Example:**
```
# Output Format
Please output complete index.html code, ensuring:
1. Perfect display on 375px width mobile screen
2. All interactive elements have proper feedback
3. Real image assets (no placeholders)
4. Proper iOS status bar and safe area simulation
5. Smooth scrolling and fixed navigation elements
The output should be immediately viewable in a browser and represent a pixel-perfect work dashboard homepage.
```
---
## Prompt Generation Workflow
When generating a prototype prompt, follow this workflow:
### Step 1: Gather Requirements
Ask the user:
- What type of application? (e.g., enterprise tool, e-commerce, social media)
- Target platform? (iOS, Android, Web, WeChat Mini Program)
- Design style preference? (WeChat Work, iOS Native, Material Design, custom)
- Key features and pages needed?
- Target users and use cases?
- Any specific content or data to display?
- Brand colors or visual preferences?
### Step 2: Select Design System
Based on requirements, choose the appropriate design system from references/design-systems.md:
- **WeChat Work**: Chinese enterprise apps, B2B tools
- **iOS Native**: iOS apps, Apple ecosystem
- **Material Design**: Android apps, Google ecosystem
- **Ant Design Mobile**: Enterprise mobile apps with complex forms
### Step 3: Structure the Prompt
Using the sections above:
1. Define the role (UI/UX engineer with specific expertise)
2. Describe the task (what to build, design style, key attributes)
3. Specify tech stack (frameworks, device, assets, CDNs)
4. Detail visual design (colors, components, layout, content)
5. Provide implementation guidelines (technical details)
6. Include Tailwind config (if using Tailwind)
7. Show content hierarchy (visual tree structure)
8. Add special requirements (design system specifics, interactions)
9. Specify output format (deliverable expectations)
### Step 4: Populate with Specifics
- Replace all template placeholders with actual values
- Include real content (text, numbers, labels)
- Specify exact colors (hex codes)
- Define precise measurements (px, rem, etc.)
- List all page elements and features
- Add interaction states and behaviors
### Step 5: Review and Refine
Ensure the prompt:
- Is complete and self-contained
- Has no ambiguous placeholders
- Includes all necessary technical details
- Specifies exact design requirements
- Provides realistic content examples
- Defines clear deliverables
---
## Quality Checklist
Before finalizing a prototype prompt, verify:
**Completeness:**
- [ ] Role clearly defined with relevant expertise
- [ ] Task explicitly states what to build and design style
- [ ] All tech stack components listed with versions/CDNs
- [ ] Complete color palette with hex codes
- [ ] All UI components specified with dimensions and styles
- [ ] Page layout fully described with measurements
- [ ] Actual content provided (not placeholders)
- [ ] Implementation details cover technical requirements
- [ ] Tailwind config included (if applicable)
- [ ] Content hierarchy visualized
- [ ] Special requirements and interactions documented
- [ ] Output format clearly defined
**Clarity:**
- [ ] No ambiguous terms or vague descriptions
- [ ] Measurements specified (px, rem, %, etc.)
- [ ] Colors defined with hex codes
- [ ] Component states described (normal, hover, active, disabled)
- [ ] Layout relationships clear (parent-child, spacing, alignment)
**Specificity:**
- [ ] Design system explicitly named
- [ ] Viewport dimensions provided
- [ ] Typography scale defined (sizes, weights, line heights)
- [ ] Interactive behaviors documented
- [ ] Edge cases considered (long text, empty states, etc.)
**Realism:**
- [ ] Real content examples (not Lorem Ipsum)
- [ ] Authentic data points (numbers, names, dates)
- [ ] Practical feature set (not overengineered)
- [ ] Appropriate complexity for use case
**Technical Accuracy:**
- [ ] Valid Tailwind class names
- [ ] Correct CDN links
- [ ] Proper HTML structure implied
- [ ] Feasible layout techniques (Flexbox/Grid)
- [ ] Accessible markup considerations
---
## Example Variations
### Minimal Prompt (for simple landing page)
```
# Role
You are a frontend developer specializing in clean, modern web design.
# Task
Create a single-page landing page (HTML + Tailwind CSS) for a SaaS product.
Style: Modern, minimal, professional with soft colors.
# Tech Stack
- Single index.html file
- Tailwind CSS (CDN)
- Desktop-first responsive (max 1200px)
- Use Unsplash for hero image
# Visual Design
- Colors: Primary #6366F1 (indigo), Background #F9FAFB (gray-50), Text #111827 (gray-900)
- Sections: Hero (full viewport height) + Features (3 columns) + CTA
- Typography: System sans-serif, Hero 48px bold, Body 18px regular
# Output Format
Single HTML file, production-ready, mobile-responsive.
```
### Complex Prompt (for multi-page app)
[Follow full template structure with all sections, detailed component specs, multiple pages, complex interactions]
---
## Tips for Effective Prompts
1. **Be Specific**: "Blue button" → "Primary button: #3478F6 background, white text, 44px height, 4px rounded corners"
2. **Provide Context**: Don't just list features, explain their purpose and how they relate to user workflows
3. **Use Real Content**: "Insurance agent dashboard" with "14 customers, ¥1,234.56 revenue" is better than "Dashboard with metrics"
4. **Reference Standards**: "Follow WeChat Work design guidelines" gives Claude a comprehensive style framework
5. **Include Edge Cases**: Mention empty states, loading states, error states, long text handling
6. **Specify Interactions**: Don't assume—explicitly state tap feedback, hover effects, animation timing
7. **Think Mobile-First**: For mobile apps, always specify touch target sizes (minimum 44px), safe areas, gesture support
8. **Consider Accessibility**: Mention color contrast, touch target sizes, semantic HTML, ARIA labels
9. **Balance Detail and Brevity**: Be thorough but organized—use sections and hierarchy to keep prompts scannable
10. **Test Your Mental Model**: Can someone else understand exactly what to build from this prompt? If not, add clarity.

116
skills/skill-rules.json Normal file
View File

@@ -0,0 +1,116 @@
{
"skills": {
"codex": {
"type": "execution",
"enforcement": "suggest",
"priority": "high",
"promptTriggers": {
"keywords": [
"refactor",
"implement",
"code change",
"bug fix",
"生成代码",
"重构",
"修复"
],
"intentPatterns": [
"(refactor|rewrite|optimi[sz]e)\\b",
"(implement|build|write).*(feature|function|module|code)",
"(fix|debug).*(bug|error|issue)"
]
}
},
"codeagent": {
"type": "execution",
"enforcement": "suggest",
"priority": "high",
"promptTriggers": {
"keywords": [
"codeagent",
"multi-backend",
"backend selection",
"parallel task",
"多后端",
"并行任务",
"gemini",
"claude backend"
],
"intentPatterns": [
"\\bcodeagent\\b",
"backend\\s+(codex|claude|gemini)",
"parallel.*task",
"multi.*backend"
]
}
},
"gh-workflow": {
"type": "domain",
"enforcement": "suggest",
"priority": "high",
"promptTriggers": {
"keywords": [
"issue",
"pr",
"pull request",
"github",
"gh workflow",
"merge"
],
"intentPatterns": [
"(create|open|update|close|review).*(pr|pull request|issue)",
"\\bgithub\\b|\\bgh\\b"
]
}
},
"product-requirements": {
"type": "domain",
"enforcement": "suggest",
"priority": "high",
"promptTriggers": {
"keywords": [
"requirements",
"prd",
"product requirements",
"feature specification",
"product owner",
"需求",
"产品需求",
"需求文档",
"功能规格"
],
"intentPatterns": [
"(product|feature).*requirement",
"\\bPRD\\b",
"requirements?.*document",
"(gather|collect|define|write).*requirement"
]
}
},
"prototype-prompt-generator": {
"type": "domain",
"enforcement": "suggest",
"priority": "medium",
"promptTriggers": {
"keywords": [
"prototype",
"ui design",
"ux design",
"mobile app design",
"web app design",
"原型",
"界面设计",
"UI设计",
"UX设计",
"移动应用设计"
],
"intentPatterns": [
"(create|generate|design).*(prototype|UI|UX|interface)",
"(mobile|web).*app.*(design|prototype)",
"prototype.*prompt",
"design.*system"
]
}
}
}
}

View File

@@ -1,76 +0,0 @@
1: import copy
1: import json
1: import unittest
1: from pathlib import Path
1: import jsonschema
1: CONFIG_PATH = Path(__file__).resolve().parents[1] / "config.json"
1: SCHEMA_PATH = Path(__file__).resolve().parents[1] / "config.schema.json"
1: ROOT = CONFIG_PATH.parent
1: def load_config():
with CONFIG_PATH.open(encoding="utf-8") as f:
return json.load(f)
1: def load_schema():
with SCHEMA_PATH.open(encoding="utf-8") as f:
return json.load(f)
2: class ConfigSchemaTest(unittest.TestCase):
1: def test_config_matches_schema(self):
config = load_config()
schema = load_schema()
jsonschema.validate(config, schema)
1: def test_required_modules_present(self):
modules = load_config()["modules"]
self.assertEqual(set(modules.keys()), {"dev", "bmad", "requirements", "essentials", "advanced"})
1: def test_enabled_defaults_and_flags(self):
modules = load_config()["modules"]
self.assertTrue(modules["dev"]["enabled"])
self.assertTrue(modules["essentials"]["enabled"])
self.assertFalse(modules["bmad"]["enabled"])
self.assertFalse(modules["requirements"]["enabled"])
self.assertFalse(modules["advanced"]["enabled"])
1: def test_operations_have_expected_shape(self):
config = load_config()
for name, module in config["modules"].items():
self.assertTrue(module["operations"], f"{name} should declare at least one operation")
for op in module["operations"]:
self.assertIn("type", op)
if op["type"] in {"copy_dir", "copy_file"}:
self.assertTrue(op.get("source"), f"{name} operation missing source")
self.assertTrue(op.get("target"), f"{name} operation missing target")
elif op["type"] == "run_command":
self.assertTrue(op.get("command"), f"{name} run_command missing command")
if "env" in op:
self.assertIsInstance(op["env"], dict)
else:
self.fail(f"Unsupported operation type: {op['type']}")
1: def test_operation_sources_exist_on_disk(self):
config = load_config()
for module in config["modules"].values():
for op in module["operations"]:
if op["type"] in {"copy_dir", "copy_file"}:
path = (ROOT / op["source"]).expanduser()
self.assertTrue(path.exists(), f"Source path not found: {path}")
1: def test_schema_rejects_invalid_operation_type(self):
config = load_config()
invalid = copy.deepcopy(config)
invalid["modules"]["dev"]["operations"][0]["type"] = "unknown_op"
schema = load_schema()
with self.assertRaises(jsonschema.exceptions.ValidationError):
jsonschema.validate(invalid, schema)
1: if __name__ == "__main__":
1: unittest.main()

View File

@@ -1,76 +0,0 @@
import copy
import json
import unittest
from pathlib import Path
import jsonschema
CONFIG_PATH = Path(__file__).resolve().parents[1] / "config.json"
SCHEMA_PATH = Path(__file__).resolve().parents[1] / "config.schema.json"
ROOT = CONFIG_PATH.parent
def load_config():
with CONFIG_PATH.open(encoding="utf-8") as f:
return json.load(f)
def load_schema():
with SCHEMA_PATH.open(encoding="utf-8") as f:
return json.load(f)
class ConfigSchemaTest(unittest.TestCase):
def test_config_matches_schema(self):
config = load_config()
schema = load_schema()
jsonschema.validate(config, schema)
def test_required_modules_present(self):
modules = load_config()["modules"]
self.assertEqual(set(modules.keys()), {"dev", "bmad", "requirements", "essentials", "advanced"})
def test_enabled_defaults_and_flags(self):
modules = load_config()["modules"]
self.assertTrue(modules["dev"]["enabled"])
self.assertTrue(modules["essentials"]["enabled"])
self.assertFalse(modules["bmad"]["enabled"])
self.assertFalse(modules["requirements"]["enabled"])
self.assertFalse(modules["advanced"]["enabled"])
def test_operations_have_expected_shape(self):
config = load_config()
for name, module in config["modules"].items():
self.assertTrue(module["operations"], f"{name} should declare at least one operation")
for op in module["operations"]:
self.assertIn("type", op)
if op["type"] in {"copy_dir", "copy_file"}:
self.assertTrue(op.get("source"), f"{name} operation missing source")
self.assertTrue(op.get("target"), f"{name} operation missing target")
elif op["type"] == "run_command":
self.assertTrue(op.get("command"), f"{name} run_command missing command")
if "env" in op:
self.assertIsInstance(op["env"], dict)
else:
self.fail(f"Unsupported operation type: {op['type']}")
def test_operation_sources_exist_on_disk(self):
config = load_config()
for module in config["modules"].values():
for op in module["operations"]:
if op["type"] in {"copy_dir", "copy_file"}:
path = (ROOT / op["source"]).expanduser()
self.assertTrue(path.exists(), f"Source path not found: {path}")
def test_schema_rejects_invalid_operation_type(self):
config = load_config()
invalid = copy.deepcopy(config)
invalid["modules"]["dev"]["operations"][0]["type"] = "unknown_op"
schema = load_schema()
with self.assertRaises(jsonschema.exceptions.ValidationError):
jsonschema.validate(invalid, schema)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,458 +0,0 @@
import json
import os
import shutil
import sys
from pathlib import Path
import pytest
import install
ROOT = Path(__file__).resolve().parents[1]
SCHEMA_PATH = ROOT / "config.schema.json"
def write_config(tmp_path: Path, config: dict) -> Path:
cfg_path = tmp_path / "config.json"
cfg_path.write_text(json.dumps(config), encoding="utf-8")
shutil.copy(SCHEMA_PATH, tmp_path / "config.schema.json")
return cfg_path
@pytest.fixture()
def valid_config(tmp_path):
sample_file = tmp_path / "sample.txt"
sample_file.write_text("hello", encoding="utf-8")
sample_dir = tmp_path / "sample_dir"
sample_dir.mkdir()
(sample_dir / "f.txt").write_text("dir", encoding="utf-8")
config = {
"version": "1.0",
"install_dir": "~/.fromconfig",
"log_file": "install.log",
"modules": {
"dev": {
"enabled": True,
"description": "dev module",
"operations": [
{"type": "copy_dir", "source": "sample_dir", "target": "devcopy"}
],
},
"bmad": {
"enabled": False,
"description": "bmad",
"operations": [
{"type": "copy_file", "source": "sample.txt", "target": "bmad.txt"}
],
},
"requirements": {
"enabled": False,
"description": "reqs",
"operations": [
{"type": "copy_file", "source": "sample.txt", "target": "req.txt"}
],
},
"essentials": {
"enabled": True,
"description": "ess",
"operations": [
{"type": "copy_file", "source": "sample.txt", "target": "ess.txt"}
],
},
"advanced": {
"enabled": False,
"description": "adv",
"operations": [
{"type": "copy_file", "source": "sample.txt", "target": "adv.txt"}
],
},
},
}
cfg_path = write_config(tmp_path, config)
return cfg_path, config
def make_ctx(tmp_path: Path) -> dict:
install_dir = tmp_path / "install"
return {
"install_dir": install_dir,
"log_file": install_dir / "install.log",
"status_file": install_dir / "installed_modules.json",
"config_dir": tmp_path,
"force": False,
}
def test_parse_args_defaults():
args = install.parse_args([])
assert args.install_dir == install.DEFAULT_INSTALL_DIR
assert args.config == "config.json"
assert args.module is None
assert args.list_modules is False
assert args.force is False
def test_parse_args_custom():
args = install.parse_args(
[
"--install-dir",
"/tmp/custom",
"--module",
"dev,bmad",
"--config",
"/tmp/cfg.json",
"--list-modules",
"--force",
]
)
assert args.install_dir == "/tmp/custom"
assert args.module == "dev,bmad"
assert args.config == "/tmp/cfg.json"
assert args.list_modules is True
assert args.force is True
def test_load_config_success(valid_config):
cfg_path, config_data = valid_config
loaded = install.load_config(str(cfg_path))
assert loaded["modules"]["dev"]["description"] == config_data["modules"]["dev"]["description"]
def test_load_config_invalid_json(tmp_path):
bad = tmp_path / "bad.json"
bad.write_text("{broken", encoding="utf-8")
shutil.copy(SCHEMA_PATH, tmp_path / "config.schema.json")
with pytest.raises(ValueError):
install.load_config(str(bad))
def test_load_config_schema_error(tmp_path):
cfg = tmp_path / "cfg.json"
cfg.write_text(json.dumps({"version": "1.0"}), encoding="utf-8")
shutil.copy(SCHEMA_PATH, tmp_path / "config.schema.json")
with pytest.raises(ValueError):
install.load_config(str(cfg))
def test_resolve_paths_respects_priority(tmp_path):
config = {
"install_dir": str(tmp_path / "from_config"),
"log_file": "logs/install.log",
"modules": {},
"version": "1.0",
}
cfg_path = write_config(tmp_path, config)
args = install.parse_args(["--config", str(cfg_path)])
ctx = install.resolve_paths(config, args)
assert ctx["install_dir"] == (tmp_path / "from_config").resolve()
assert ctx["log_file"] == (tmp_path / "from_config" / "logs" / "install.log").resolve()
assert ctx["config_dir"] == tmp_path.resolve()
cli_args = install.parse_args(
["--install-dir", str(tmp_path / "cli_dir"), "--config", str(cfg_path)]
)
ctx_cli = install.resolve_paths(config, cli_args)
assert ctx_cli["install_dir"] == (tmp_path / "cli_dir").resolve()
def test_list_modules_output(valid_config, capsys):
_, config_data = valid_config
install.list_modules(config_data)
captured = capsys.readouterr().out
assert "dev" in captured
assert "essentials" in captured
assert "" in captured
def test_select_modules_behaviour(valid_config):
_, config_data = valid_config
selected_default = install.select_modules(config_data, None)
assert set(selected_default.keys()) == {"dev", "essentials"}
selected_specific = install.select_modules(config_data, "bmad")
assert set(selected_specific.keys()) == {"bmad"}
with pytest.raises(ValueError):
install.select_modules(config_data, "missing")
def test_ensure_install_dir(tmp_path, monkeypatch):
target = tmp_path / "install_here"
install.ensure_install_dir(target)
assert target.is_dir()
file_path = tmp_path / "conflict"
file_path.write_text("x", encoding="utf-8")
with pytest.raises(NotADirectoryError):
install.ensure_install_dir(file_path)
blocked = tmp_path / "blocked"
real_access = os.access
def fake_access(path, mode):
if Path(path) == blocked:
return False
return real_access(path, mode)
monkeypatch.setattr(os, "access", fake_access)
with pytest.raises(PermissionError):
install.ensure_install_dir(blocked)
def test_op_copy_dir_respects_force(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
src = tmp_path / "src"
src.mkdir()
(src / "a.txt").write_text("one", encoding="utf-8")
op = {"type": "copy_dir", "source": "src", "target": "dest"}
install.op_copy_dir(op, ctx)
target_file = ctx["install_dir"] / "dest" / "a.txt"
assert target_file.read_text(encoding="utf-8") == "one"
(src / "a.txt").write_text("two", encoding="utf-8")
install.op_copy_dir(op, ctx)
assert target_file.read_text(encoding="utf-8") == "one"
ctx["force"] = True
install.op_copy_dir(op, ctx)
assert target_file.read_text(encoding="utf-8") == "two"
def test_op_copy_file_behaviour(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
src = tmp_path / "file.txt"
src.write_text("first", encoding="utf-8")
op = {"type": "copy_file", "source": "file.txt", "target": "out/file.txt"}
install.op_copy_file(op, ctx)
dst = ctx["install_dir"] / "out" / "file.txt"
assert dst.read_text(encoding="utf-8") == "first"
src.write_text("second", encoding="utf-8")
install.op_copy_file(op, ctx)
assert dst.read_text(encoding="utf-8") == "first"
ctx["force"] = True
install.op_copy_file(op, ctx)
assert dst.read_text(encoding="utf-8") == "second"
def test_op_run_command_success(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
install.op_run_command({"type": "run_command", "command": "echo hello"}, ctx)
log_content = ctx["log_file"].read_text(encoding="utf-8")
assert "hello" in log_content
def test_op_run_command_failure(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
with pytest.raises(RuntimeError):
install.op_run_command(
{"type": "run_command", "command": f"{sys.executable} -c 'import sys; sys.exit(2)'"},
ctx,
)
log_content = ctx["log_file"].read_text(encoding="utf-8")
assert "returncode: 2" in log_content
def test_execute_module_success(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
src = tmp_path / "src.txt"
src.write_text("data", encoding="utf-8")
cfg = {"operations": [{"type": "copy_file", "source": "src.txt", "target": "out.txt"}]}
result = install.execute_module("demo", cfg, ctx)
assert result["status"] == "success"
assert (ctx["install_dir"] / "out.txt").read_text(encoding="utf-8") == "data"
def test_execute_module_failure_logs_and_stops(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
cfg = {"operations": [{"type": "unknown", "source": "", "target": ""}]}
with pytest.raises(ValueError):
install.execute_module("demo", cfg, ctx)
log_content = ctx["log_file"].read_text(encoding="utf-8")
assert "failed on unknown" in log_content
def test_write_log_and_status(tmp_path):
ctx = make_ctx(tmp_path)
install.ensure_install_dir(ctx["install_dir"])
install.write_log({"level": "INFO", "message": "hello"}, ctx)
content = ctx["log_file"].read_text(encoding="utf-8")
assert "hello" in content
results = [
{"module": "dev", "status": "success", "operations": [], "installed_at": "ts"}
]
install.write_status(results, ctx)
status_data = json.loads(ctx["status_file"].read_text(encoding="utf-8"))
assert status_data["modules"]["dev"]["status"] == "success"
def test_main_success(valid_config, tmp_path):
cfg_path, _ = valid_config
install_dir = tmp_path / "install_final"
rc = install.main(
[
"--config",
str(cfg_path),
"--install-dir",
str(install_dir),
"--module",
"dev",
]
)
assert rc == 0
assert (install_dir / "devcopy" / "f.txt").exists()
assert (install_dir / "installed_modules.json").exists()
def test_main_failure_without_force(tmp_path):
cfg = {
"version": "1.0",
"install_dir": "~/.claude",
"log_file": "install.log",
"modules": {
"dev": {
"enabled": True,
"description": "dev",
"operations": [
{
"type": "run_command",
"command": f"{sys.executable} -c 'import sys; sys.exit(3)'",
}
],
},
"bmad": {
"enabled": False,
"description": "bmad",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "t.txt"}
],
},
"requirements": {
"enabled": False,
"description": "reqs",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "r.txt"}
],
},
"essentials": {
"enabled": False,
"description": "ess",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "e.txt"}
],
},
"advanced": {
"enabled": False,
"description": "adv",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "a.txt"}
],
},
},
}
cfg_path = write_config(tmp_path, cfg)
install_dir = tmp_path / "fail_install"
rc = install.main(
[
"--config",
str(cfg_path),
"--install-dir",
str(install_dir),
"--module",
"dev",
]
)
assert rc == 1
assert not (install_dir / "installed_modules.json").exists()
def test_main_force_records_failure(tmp_path):
cfg = {
"version": "1.0",
"install_dir": "~/.claude",
"log_file": "install.log",
"modules": {
"dev": {
"enabled": True,
"description": "dev",
"operations": [
{
"type": "run_command",
"command": f"{sys.executable} -c 'import sys; sys.exit(4)'",
}
],
},
"bmad": {
"enabled": False,
"description": "bmad",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "t.txt"}
],
},
"requirements": {
"enabled": False,
"description": "reqs",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "r.txt"}
],
},
"essentials": {
"enabled": False,
"description": "ess",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "e.txt"}
],
},
"advanced": {
"enabled": False,
"description": "adv",
"operations": [
{"type": "copy_file", "source": "s.txt", "target": "a.txt"}
],
},
},
}
cfg_path = write_config(tmp_path, cfg)
install_dir = tmp_path / "force_install"
rc = install.main(
[
"--config",
str(cfg_path),
"--install-dir",
str(install_dir),
"--module",
"dev",
"--force",
]
)
assert rc == 0
status = json.loads((install_dir / "installed_modules.json").read_text(encoding="utf-8"))
assert status["modules"]["dev"]["status"] == "failed"

View File

@@ -1,224 +0,0 @@
import json
import shutil
import sys
from pathlib import Path
import pytest
import install
ROOT = Path(__file__).resolve().parents[1]
SCHEMA_PATH = ROOT / "config.schema.json"
def _write_schema(target_dir: Path) -> None:
shutil.copy(SCHEMA_PATH, target_dir / "config.schema.json")
def _base_config(install_dir: Path, modules: dict) -> dict:
return {
"version": "1.0",
"install_dir": str(install_dir),
"log_file": "install.log",
"modules": modules,
}
def _prepare_env(tmp_path: Path, modules: dict) -> tuple[Path, Path, Path]:
"""Create a temp config directory with schema and config.json."""
config_dir = tmp_path / "config"
install_dir = tmp_path / "install"
config_dir.mkdir()
_write_schema(config_dir)
cfg_path = config_dir / "config.json"
cfg_path.write_text(
json.dumps(_base_config(install_dir, modules)), encoding="utf-8"
)
return cfg_path, install_dir, config_dir
def _sample_sources(config_dir: Path) -> dict:
sample_dir = config_dir / "sample_dir"
sample_dir.mkdir()
(sample_dir / "nested.txt").write_text("dir-content", encoding="utf-8")
sample_file = config_dir / "sample.txt"
sample_file.write_text("file-content", encoding="utf-8")
return {"dir": sample_dir, "file": sample_file}
def _read_status(install_dir: Path) -> dict:
return json.loads((install_dir / "installed_modules.json").read_text("utf-8"))
def test_single_module_full_flow(tmp_path):
cfg_path, install_dir, config_dir = _prepare_env(
tmp_path,
{
"solo": {
"enabled": True,
"description": "single module",
"operations": [
{"type": "copy_dir", "source": "sample_dir", "target": "payload"},
{
"type": "copy_file",
"source": "sample.txt",
"target": "payload/sample.txt",
},
{
"type": "run_command",
"command": f"{sys.executable} -c \"from pathlib import Path; Path('run.txt').write_text('ok', encoding='utf-8')\"",
},
],
}
},
)
_sample_sources(config_dir)
rc = install.main(["--config", str(cfg_path), "--module", "solo"])
assert rc == 0
assert (install_dir / "payload" / "nested.txt").read_text(encoding="utf-8") == "dir-content"
assert (install_dir / "payload" / "sample.txt").read_text(encoding="utf-8") == "file-content"
assert (install_dir / "run.txt").read_text(encoding="utf-8") == "ok"
status = _read_status(install_dir)
assert status["modules"]["solo"]["status"] == "success"
assert len(status["modules"]["solo"]["operations"]) == 3
def test_multi_module_install_and_status(tmp_path):
modules = {
"alpha": {
"enabled": True,
"description": "alpha",
"operations": [
{
"type": "copy_file",
"source": "sample.txt",
"target": "alpha.txt",
}
],
},
"beta": {
"enabled": True,
"description": "beta",
"operations": [
{
"type": "copy_dir",
"source": "sample_dir",
"target": "beta_dir",
}
],
},
}
cfg_path, install_dir, config_dir = _prepare_env(tmp_path, modules)
_sample_sources(config_dir)
rc = install.main(["--config", str(cfg_path)])
assert rc == 0
assert (install_dir / "alpha.txt").read_text(encoding="utf-8") == "file-content"
assert (install_dir / "beta_dir" / "nested.txt").exists()
status = _read_status(install_dir)
assert set(status["modules"].keys()) == {"alpha", "beta"}
assert all(mod["status"] == "success" for mod in status["modules"].values())
def test_force_overwrites_existing_files(tmp_path):
modules = {
"forcey": {
"enabled": True,
"description": "force copy",
"operations": [
{
"type": "copy_file",
"source": "sample.txt",
"target": "target.txt",
}
],
}
}
cfg_path, install_dir, config_dir = _prepare_env(tmp_path, modules)
sources = _sample_sources(config_dir)
install.main(["--config", str(cfg_path), "--module", "forcey"])
assert (install_dir / "target.txt").read_text(encoding="utf-8") == "file-content"
sources["file"].write_text("new-content", encoding="utf-8")
rc = install.main(["--config", str(cfg_path), "--module", "forcey", "--force"])
assert rc == 0
assert (install_dir / "target.txt").read_text(encoding="utf-8") == "new-content"
status = _read_status(install_dir)
assert status["modules"]["forcey"]["status"] == "success"
def test_failure_triggers_rollback_and_restores_status(tmp_path):
# First successful run to create a known-good status file.
ok_modules = {
"stable": {
"enabled": True,
"description": "stable",
"operations": [
{
"type": "copy_file",
"source": "sample.txt",
"target": "stable.txt",
}
],
}
}
cfg_path, install_dir, config_dir = _prepare_env(tmp_path, ok_modules)
_sample_sources(config_dir)
assert install.main(["--config", str(cfg_path)]) == 0
pre_status = _read_status(install_dir)
assert "stable" in pre_status["modules"]
# Rewrite config to introduce a failing module.
failing_modules = {
**ok_modules,
"broken": {
"enabled": True,
"description": "will fail",
"operations": [
{
"type": "copy_file",
"source": "sample.txt",
"target": "broken.txt",
},
{
"type": "run_command",
"command": f"{sys.executable} -c 'import sys; sys.exit(5)'",
},
],
},
}
cfg_path.write_text(
json.dumps(_base_config(install_dir, failing_modules)), encoding="utf-8"
)
rc = install.main(["--config", str(cfg_path)])
assert rc == 1
# The failed module's file should have been removed by rollback.
assert not (install_dir / "broken.txt").exists()
# Previously installed files remain.
assert (install_dir / "stable.txt").exists()
restored_status = _read_status(install_dir)
assert restored_status == pre_status
log_content = (install_dir / "install.log").read_text(encoding="utf-8")
assert "Rolling back" in log_content