mirror of
https://github.com/cexll/myclaude.git
synced 2026-02-06 02:34:09 +08:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fa6843d9 | ||
|
|
74e4d181c2 | ||
|
|
04fa1626ae | ||
|
|
c0f61d5cc2 | ||
|
|
716d1eb173 | ||
|
|
4bc9ffa907 | ||
|
|
c6c2f93e02 | ||
|
|
cd3115446d | ||
|
|
2b8bfd714c | ||
|
|
71485558df | ||
|
|
b711b44c0e | ||
|
|
eda2475543 | ||
|
|
2c0553794a | ||
|
|
c96193fca6 | ||
|
|
e2cd5be812 | ||
|
|
3dfa447f10 | ||
|
|
e9a8013c6f | ||
|
|
3d76d46336 |
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -8,7 +8,10 @@ on:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -21,11 +24,13 @@ jobs:
|
||||
run: |
|
||||
cd codeagent-wrapper
|
||||
go test -v -cover -coverprofile=coverage.out ./...
|
||||
shell: bash
|
||||
|
||||
- name: Check coverage
|
||||
run: |
|
||||
cd codeagent-wrapper
|
||||
go tool cover -func=coverage.out | grep total | awk '{print $3}'
|
||||
shell: bash
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v4
|
||||
|
||||
31
.github/workflows/release.yml
vendored
31
.github/workflows/release.yml
vendored
@@ -74,7 +74,7 @@ jobs:
|
||||
if [ "${{ matrix.goos }}" = "windows" ]; then
|
||||
OUTPUT_NAME="${OUTPUT_NAME}.exe"
|
||||
fi
|
||||
go build -ldflags="-s -w -X main.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent-wrapper
|
||||
go build -ldflags="-s -w -X codeagent-wrapper/internal/app.version=${VERSION}" -o ${OUTPUT_NAME} ./cmd/codeagent-wrapper
|
||||
chmod +x ${OUTPUT_NAME}
|
||||
echo "artifact_path=codeagent-wrapper/${OUTPUT_NAME}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -91,6 +91,33 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate Release Notes
|
||||
id: release_notes
|
||||
run: |
|
||||
# Get previous tag
|
||||
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^${{ github.ref_name }}$" | head -n 1)
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous tag found, using all commits"
|
||||
COMMITS=$(git log --pretty=format:"- %s (%h)" --no-merges)
|
||||
else
|
||||
echo "Generating notes from $PREVIOUS_TAG to ${{ github.ref_name }}"
|
||||
COMMITS=$(git log ${PREVIOUS_TAG}..${{ github.ref_name }} --pretty=format:"- %s (%h)" --no-merges)
|
||||
fi
|
||||
|
||||
# Create release notes
|
||||
cat > release_notes.md <<EOF
|
||||
## What's Changed
|
||||
|
||||
${COMMITS}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${{ github.ref_name }}
|
||||
EOF
|
||||
|
||||
cat release_notes.md
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -108,6 +135,6 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release/*
|
||||
generate_release_notes: true
|
||||
body_path: release_notes.md
|
||||
draft: false
|
||||
prerelease: false
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ __pycache__
|
||||
.coverage
|
||||
coverage.out
|
||||
references
|
||||
output/
|
||||
.worktrees/
|
||||
|
||||
@@ -36,10 +36,15 @@ npx github:cexll/myclaude
|
||||
# List installable items (modules / skills / wrapper)
|
||||
npx github:cexll/myclaude --list
|
||||
|
||||
# Detect installed modules and update from GitHub
|
||||
npx github:cexll/myclaude --update
|
||||
|
||||
# Custom install directory / overwrite
|
||||
npx github:cexll/myclaude --install-dir ~/.claude --force
|
||||
```
|
||||
|
||||
`--update` detects already installed modules in the target install dir (defaults to `~/.claude`, via `installed_modules.json` when present) and updates them from GitHub (latest release) by overwriting the module files.
|
||||
|
||||
### Module Configuration
|
||||
|
||||
Edit `config.json` to enable/disable modules:
|
||||
|
||||
@@ -174,10 +174,15 @@ npx github:cexll/myclaude
|
||||
# 列出可安装项(module:* / skill:* / codeagent-wrapper)
|
||||
npx github:cexll/myclaude --list
|
||||
|
||||
# 检测已安装 modules 并从 GitHub 更新
|
||||
npx github:cexll/myclaude --update
|
||||
|
||||
# 指定安装目录 / 强制覆盖
|
||||
npx github:cexll/myclaude --install-dir ~/.claude --force
|
||||
```
|
||||
|
||||
`--update` 会在目标安装目录(默认 `~/.claude`,优先读取 `installed_modules.json`)检测已安装 modules,并从 GitHub 拉取最新发布版本覆盖更新。
|
||||
|
||||
### 模块配置
|
||||
|
||||
编辑 `config.json` 启用/禁用模块:
|
||||
|
||||
455
bin/cli.js
455
bin/cli.js
@@ -18,15 +18,24 @@ const API_HEADERS = {
|
||||
|
||||
function parseArgs(argv) {
|
||||
const out = {
|
||||
command: "install",
|
||||
installDir: "~/.claude",
|
||||
force: false,
|
||||
dryRun: false,
|
||||
list: false,
|
||||
update: false,
|
||||
tag: null,
|
||||
module: null,
|
||||
yes: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
let i = 0;
|
||||
if (argv[i] && !argv[i].startsWith("-")) {
|
||||
out.command = argv[i];
|
||||
i++;
|
||||
}
|
||||
|
||||
for (; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === "--install-dir") out.installDir = argv[++i];
|
||||
else if (a === "--force") out.force = true;
|
||||
@@ -34,6 +43,8 @@ function parseArgs(argv) {
|
||||
else if (a === "--list") out.list = true;
|
||||
else if (a === "--update") out.update = true;
|
||||
else if (a === "--tag") out.tag = argv[++i];
|
||||
else if (a === "--module") out.module = argv[++i];
|
||||
else if (a === "-y" || a === "--yes") out.yes = true;
|
||||
else if (a === "-h" || a === "--help") out.help = true;
|
||||
else throw new Error(`Unknown arg: ${a}`);
|
||||
}
|
||||
@@ -51,6 +62,8 @@ function printHelp() {
|
||||
" npx github:cexll/myclaude --list",
|
||||
" npx github:cexll/myclaude --update",
|
||||
" npx github:cexll/myclaude --install-dir ~/.claude --force",
|
||||
" npx github:cexll/myclaude uninstall",
|
||||
" npx github:cexll/myclaude uninstall --module bmad,do -y",
|
||||
"",
|
||||
"Options:",
|
||||
" --install-dir <path> Default: ~/.claude",
|
||||
@@ -59,6 +72,8 @@ function printHelp() {
|
||||
" --list List installable items and exit",
|
||||
" --update Update already installed modules",
|
||||
" --tag <tag> Install a specific GitHub tag",
|
||||
" --module <names> For uninstall: comma-separated module names",
|
||||
" -y, --yes For uninstall: skip confirmation prompt",
|
||||
].join("\n") + "\n"
|
||||
);
|
||||
}
|
||||
@@ -202,6 +217,187 @@ function readInstalledModuleNamesFromStatus(installDir) {
|
||||
}
|
||||
}
|
||||
|
||||
function loadInstalledStatus(installDir) {
|
||||
const p = path.join(installDir, "installed_modules.json");
|
||||
if (!fs.existsSync(p)) return { modules: {} };
|
||||
try {
|
||||
const json = JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
const modules = json && json.modules;
|
||||
if (!modules || typeof modules !== "object" || Array.isArray(modules)) return { modules: {} };
|
||||
return { ...json, modules };
|
||||
} catch {
|
||||
return { modules: {} };
|
||||
}
|
||||
}
|
||||
|
||||
function saveInstalledStatus(installDir, status) {
|
||||
const p = path.join(installDir, "installed_modules.json");
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(status, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function upsertModuleStatus(installDir, moduleResult) {
|
||||
const status = loadInstalledStatus(installDir);
|
||||
status.modules = status.modules || {};
|
||||
status.modules[moduleResult.module] = moduleResult;
|
||||
status.updated_at = new Date().toISOString();
|
||||
saveInstalledStatus(installDir, status);
|
||||
}
|
||||
|
||||
function deleteModuleStatus(installDir, moduleName) {
|
||||
const status = loadInstalledStatus(installDir);
|
||||
if (status.modules && Object.prototype.hasOwnProperty.call(status.modules, moduleName)) {
|
||||
delete status.modules[moduleName];
|
||||
status.updated_at = new Date().toISOString();
|
||||
saveInstalledStatus(installDir, status);
|
||||
}
|
||||
}
|
||||
|
||||
function loadSettings(installDir) {
|
||||
const p = path.join(installDir, "settings.json");
|
||||
if (!fs.existsSync(p)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function saveSettings(installDir, settings) {
|
||||
const p = path.join(installDir, "settings.json");
|
||||
fs.mkdirSync(installDir, { recursive: true });
|
||||
fs.writeFileSync(p, JSON.stringify(settings, null, 2) + "\n", "utf8");
|
||||
}
|
||||
|
||||
function isPlainObject(x) {
|
||||
return !!x && typeof x === "object" && !Array.isArray(x);
|
||||
}
|
||||
|
||||
function deepEqual(a, b) {
|
||||
if (a === b) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
|
||||
return true;
|
||||
}
|
||||
if (isPlainObject(a) && isPlainObject(b)) {
|
||||
const aKeys = Object.keys(a);
|
||||
const bKeys = Object.keys(b);
|
||||
if (aKeys.length !== bKeys.length) return false;
|
||||
for (const k of aKeys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(b, k)) return false;
|
||||
if (!deepEqual(a[k], b[k])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hooksEqual(h1, h2) {
|
||||
if (!isPlainObject(h1) || !isPlainObject(h2)) return false;
|
||||
const a = { ...h1 };
|
||||
const b = { ...h2 };
|
||||
delete a.__module__;
|
||||
delete b.__module__;
|
||||
return deepEqual(a, b);
|
||||
}
|
||||
|
||||
function replaceHookVariables(obj, pluginRoot) {
|
||||
if (typeof obj === "string") return obj.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, pluginRoot);
|
||||
if (Array.isArray(obj)) return obj.map((v) => replaceHookVariables(v, pluginRoot));
|
||||
if (isPlainObject(obj)) {
|
||||
const out = {};
|
||||
for (const [k, v] of Object.entries(obj)) out[k] = replaceHookVariables(v, pluginRoot);
|
||||
return out;
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function mergeHooksToSettings(moduleName, hooksConfig, installDir, pluginRoot) {
|
||||
if (!hooksConfig || !isPlainObject(hooksConfig)) return false;
|
||||
const rawHooks = hooksConfig.hooks;
|
||||
if (!rawHooks || !isPlainObject(rawHooks)) return false;
|
||||
|
||||
const settings = loadSettings(installDir);
|
||||
if (!settings.hooks || !isPlainObject(settings.hooks)) settings.hooks = {};
|
||||
|
||||
const moduleHooks = pluginRoot ? replaceHookVariables(rawHooks, pluginRoot) : rawHooks;
|
||||
let modified = false;
|
||||
|
||||
for (const [hookType, hookEntries] of Object.entries(moduleHooks)) {
|
||||
if (!Array.isArray(hookEntries)) continue;
|
||||
if (!Array.isArray(settings.hooks[hookType])) settings.hooks[hookType] = [];
|
||||
|
||||
for (const entry of hookEntries) {
|
||||
if (!isPlainObject(entry)) continue;
|
||||
const entryCopy = { ...entry, __module__: moduleName };
|
||||
|
||||
let exists = false;
|
||||
for (const existing of settings.hooks[hookType]) {
|
||||
if (existing && existing.__module__ === moduleName && hooksEqual(existing, entryCopy)) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!exists) {
|
||||
settings.hooks[hookType].push(entryCopy);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) saveSettings(installDir, settings);
|
||||
return modified;
|
||||
}
|
||||
|
||||
function unmergeHooksFromSettings(moduleName, installDir) {
|
||||
const settings = loadSettings(installDir);
|
||||
if (!settings.hooks || !isPlainObject(settings.hooks)) return false;
|
||||
|
||||
let modified = false;
|
||||
for (const hookType of Object.keys(settings.hooks)) {
|
||||
const entries = settings.hooks[hookType];
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const kept = entries.filter((e) => !(e && e.__module__ === moduleName));
|
||||
if (kept.length !== entries.length) {
|
||||
settings.hooks[hookType] = kept;
|
||||
modified = true;
|
||||
}
|
||||
if (!settings.hooks[hookType].length) {
|
||||
delete settings.hooks[hookType];
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) saveSettings(installDir, settings);
|
||||
return modified;
|
||||
}
|
||||
|
||||
function mergeModuleHooks(moduleName, mod, installDir) {
|
||||
const ops = Array.isArray(mod && mod.operations) ? mod.operations : [];
|
||||
let merged = false;
|
||||
|
||||
for (const op of ops) {
|
||||
if (!op || op.type !== "copy_dir") continue;
|
||||
const target = typeof op.target === "string" ? op.target : "";
|
||||
if (!target) continue;
|
||||
|
||||
const targetDir = path.join(installDir, target);
|
||||
const hooksFile = path.join(targetDir, "hooks", "hooks.json");
|
||||
if (!fs.existsSync(hooksFile)) continue;
|
||||
|
||||
let hooksConfig;
|
||||
try {
|
||||
hooksConfig = JSON.parse(fs.readFileSync(hooksFile, "utf8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (mergeHooksToSettings(moduleName, hooksConfig, installDir, targetDir)) merged = true;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function dirExists(p) {
|
||||
try {
|
||||
return (await fs.promises.stat(p)).isDirectory();
|
||||
@@ -305,7 +501,8 @@ async function updateInstalledModules(installDir, tag, config, dryRun) {
|
||||
await fs.promises.mkdir(installDir, { recursive: true });
|
||||
for (const name of toUpdate) {
|
||||
process.stdout.write(`Updating module: ${name}\n`);
|
||||
await applyModule(name, config, repoRoot, installDir, true);
|
||||
const r = await applyModule(name, config, repoRoot, installDir, true, tag);
|
||||
upsertModuleStatus(installDir, r);
|
||||
}
|
||||
} finally {
|
||||
if (tmp) await rmTree(tmp);
|
||||
@@ -363,6 +560,7 @@ async function promptMultiSelect(items, title) {
|
||||
function cleanup() {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.removeListener("keypress", onKey);
|
||||
process.stdin.pause();
|
||||
}
|
||||
|
||||
function onKey(_, key) {
|
||||
@@ -513,11 +711,12 @@ async function extractTarGz(archivePath, destDir) {
|
||||
}
|
||||
|
||||
async function copyFile(src, dst, force) {
|
||||
if (!force && fs.existsSync(dst)) return;
|
||||
if (!force && fs.existsSync(dst)) return false;
|
||||
await fs.promises.mkdir(path.dirname(dst), { recursive: true });
|
||||
await fs.promises.copyFile(src, dst);
|
||||
const st = await fs.promises.stat(src);
|
||||
await fs.promises.chmod(dst, st.mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function copyDirRecursive(src, dst, force) {
|
||||
@@ -534,6 +733,7 @@ async function copyDirRecursive(src, dst, force) {
|
||||
}
|
||||
|
||||
async function mergeDir(src, installDir, force) {
|
||||
const installed = [];
|
||||
const subdirs = await fs.promises.readdir(src, { withFileTypes: true });
|
||||
for (const d of subdirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
@@ -543,19 +743,23 @@ async function mergeDir(src, installDir, force) {
|
||||
const entries = await fs.promises.readdir(srcSub, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
await copyFile(path.join(srcSub, e.name), path.join(dstSub, e.name), force);
|
||||
const didCopy = await copyFile(path.join(srcSub, e.name), path.join(dstSub, e.name), force);
|
||||
if (didCopy) installed.push(`${d.name}/${e.name}`);
|
||||
}
|
||||
}
|
||||
return installed;
|
||||
}
|
||||
|
||||
function runInstallSh(repoRoot, installDir) {
|
||||
function runInstallSh(repoRoot, installDir, tag) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cmd = process.platform === "win32" ? "cmd.exe" : "bash";
|
||||
const args = process.platform === "win32" ? ["/c", "install.bat"] : ["install.sh"];
|
||||
const env = { ...process.env, INSTALL_DIR: installDir };
|
||||
if (tag) env.CODEAGENT_WRAPPER_VERSION = tag;
|
||||
const p = spawn(cmd, args, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
env: { ...process.env, INSTALL_DIR: installDir },
|
||||
env,
|
||||
});
|
||||
p.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
@@ -573,37 +777,158 @@ async function rmTree(p) {
|
||||
await fs.promises.rmdir(p, { recursive: true });
|
||||
}
|
||||
|
||||
async function applyModule(moduleName, config, repoRoot, installDir, force) {
|
||||
async function applyModule(moduleName, config, repoRoot, installDir, force, tag) {
|
||||
const mod = config && config.modules && config.modules[moduleName];
|
||||
if (!mod) throw new Error(`Unknown module: ${moduleName}`);
|
||||
const ops = Array.isArray(mod.operations) ? mod.operations : [];
|
||||
const result = {
|
||||
module: moduleName,
|
||||
status: "success",
|
||||
operations: [],
|
||||
installed_at: new Date().toISOString(),
|
||||
};
|
||||
const mergeDirFiles = [];
|
||||
|
||||
for (const op of ops) {
|
||||
const type = op && op.type;
|
||||
if (type === "copy_file") {
|
||||
await copyFile(
|
||||
path.join(repoRoot, op.source),
|
||||
path.join(installDir, op.target),
|
||||
force
|
||||
);
|
||||
} else if (type === "copy_dir") {
|
||||
await copyDirRecursive(
|
||||
path.join(repoRoot, op.source),
|
||||
path.join(installDir, op.target),
|
||||
force
|
||||
);
|
||||
} else if (type === "merge_dir") {
|
||||
await mergeDir(path.join(repoRoot, op.source), installDir, force);
|
||||
} else if (type === "run_command") {
|
||||
const cmd = typeof op.command === "string" ? op.command.trim() : "";
|
||||
if (cmd !== "bash install.sh") {
|
||||
throw new Error(`Refusing run_command: ${cmd || "(empty)"}`);
|
||||
try {
|
||||
if (type === "copy_file") {
|
||||
await copyFile(path.join(repoRoot, op.source), path.join(installDir, op.target), force);
|
||||
} else if (type === "copy_dir") {
|
||||
await copyDirRecursive(path.join(repoRoot, op.source), path.join(installDir, op.target), force);
|
||||
} else if (type === "merge_dir") {
|
||||
mergeDirFiles.push(...(await mergeDir(path.join(repoRoot, op.source), installDir, force)));
|
||||
} else if (type === "run_command") {
|
||||
const cmd = typeof op.command === "string" ? op.command.trim() : "";
|
||||
if (cmd !== "bash install.sh") {
|
||||
throw new Error(`Refusing run_command: ${cmd || "(empty)"}`);
|
||||
}
|
||||
await runInstallSh(repoRoot, installDir, tag);
|
||||
} else {
|
||||
throw new Error(`Unsupported operation type: ${type}`);
|
||||
}
|
||||
await runInstallSh(repoRoot, installDir);
|
||||
} else {
|
||||
throw new Error(`Unsupported operation type: ${type}`);
|
||||
result.operations.push({ type, status: "success" });
|
||||
} catch (err) {
|
||||
result.status = "failed";
|
||||
result.operations.push({
|
||||
type,
|
||||
status: "failed",
|
||||
error: err && err.message ? err.message : String(err),
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (mergeDirFiles.length) result.merge_dir_files = mergeDirFiles;
|
||||
|
||||
try {
|
||||
if (mergeModuleHooks(moduleName, mod, installDir)) {
|
||||
result.has_hooks = true;
|
||||
result.operations.push({ type: "merge_hooks", status: "success" });
|
||||
}
|
||||
} catch (err) {
|
||||
result.operations.push({
|
||||
type: "merge_hooks",
|
||||
status: "failed",
|
||||
error: err && err.message ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function tryRemoveEmptyDir(p) {
|
||||
try {
|
||||
const entries = await fs.promises.readdir(p);
|
||||
if (!entries.length) await fs.promises.rmdir(p);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function removePathIfExists(p) {
|
||||
if (!fs.existsSync(p)) return;
|
||||
const st = await fs.promises.lstat(p);
|
||||
if (st.isDirectory()) {
|
||||
await rmTree(p);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await fs.promises.unlink(p);
|
||||
} catch (err) {
|
||||
if (!err || err.code !== "ENOENT") throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallModule(moduleName, config, repoRoot, installDir, dryRun) {
|
||||
const mod = config && config.modules && config.modules[moduleName];
|
||||
if (!mod) throw new Error(`Unknown module: ${moduleName}`);
|
||||
const ops = Array.isArray(mod.operations) ? mod.operations : [];
|
||||
const status = loadInstalledStatus(installDir);
|
||||
const moduleStatus = (status.modules && status.modules[moduleName]) || {};
|
||||
const recordedMerge = Array.isArray(moduleStatus.merge_dir_files) ? moduleStatus.merge_dir_files : null;
|
||||
|
||||
for (const op of ops) {
|
||||
const type = op && op.type;
|
||||
if (type === "copy_file" || type === "copy_dir") {
|
||||
const target = typeof op.target === "string" ? op.target : "";
|
||||
if (!target) continue;
|
||||
const p = path.join(installDir, target);
|
||||
if (dryRun) process.stdout.write(`- remove ${p}\n`);
|
||||
else await removePathIfExists(p);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (type !== "merge_dir") continue;
|
||||
const source = typeof op.source === "string" ? op.source : "";
|
||||
if (!source) continue;
|
||||
|
||||
if (recordedMerge && recordedMerge.length) {
|
||||
for (const rel of recordedMerge) {
|
||||
const parts = String(rel).split("/").filter(Boolean);
|
||||
if (parts.includes("..")) continue;
|
||||
const p = path.join(installDir, ...parts);
|
||||
if (dryRun) process.stdout.write(`- remove ${p}\n`);
|
||||
else {
|
||||
await removePathIfExists(p);
|
||||
await tryRemoveEmptyDir(path.dirname(p));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const srcDir = path.join(repoRoot, source);
|
||||
if (!(await dirExists(srcDir))) continue;
|
||||
const subdirs = await fs.promises.readdir(srcDir, { withFileTypes: true });
|
||||
for (const d of subdirs) {
|
||||
if (!d.isDirectory()) continue;
|
||||
const srcSub = path.join(srcDir, d.name);
|
||||
const entries = await fs.promises.readdir(srcSub, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
if (!e.isFile()) continue;
|
||||
const dst = path.join(installDir, d.name, e.name);
|
||||
if (!fs.existsSync(dst)) continue;
|
||||
try {
|
||||
const [srcBuf, dstBuf] = await Promise.all([
|
||||
fs.promises.readFile(path.join(srcSub, e.name)),
|
||||
fs.promises.readFile(dst),
|
||||
]);
|
||||
if (Buffer.compare(srcBuf, dstBuf) !== 0) continue;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (dryRun) process.stdout.write(`- remove ${dst}\n`);
|
||||
else {
|
||||
await removePathIfExists(dst);
|
||||
await tryRemoveEmptyDir(path.dirname(dst));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) return;
|
||||
unmergeHooksFromSettings(moduleName, installDir);
|
||||
deleteModuleStatus(installDir, moduleName);
|
||||
}
|
||||
|
||||
async function installSelected(picks, tag, config, installDir, force, dryRun) {
|
||||
@@ -642,12 +967,13 @@ async function installSelected(picks, tag, config, installDir, force, dryRun) {
|
||||
for (const p of picks) {
|
||||
if (p.kind === "wrapper") {
|
||||
process.stdout.write("Installing codeagent-wrapper...\n");
|
||||
await runInstallSh(repoRoot, installDir);
|
||||
await runInstallSh(repoRoot, installDir, tag);
|
||||
continue;
|
||||
}
|
||||
if (p.kind === "module") {
|
||||
process.stdout.write(`Installing module: ${p.moduleName}\n`);
|
||||
await applyModule(p.moduleName, config, repoRoot, installDir, force);
|
||||
const r = await applyModule(p.moduleName, config, repoRoot, installDir, force, tag);
|
||||
upsertModuleStatus(installDir, r);
|
||||
continue;
|
||||
}
|
||||
if (p.kind === "skill") {
|
||||
@@ -672,8 +998,77 @@ async function main() {
|
||||
}
|
||||
|
||||
const installDir = expandHome(args.installDir);
|
||||
if (args.command !== "install" && args.command !== "uninstall") {
|
||||
throw new Error(`Unknown command: ${args.command}`);
|
||||
}
|
||||
if (args.list && args.update) throw new Error("Cannot combine --list and --update");
|
||||
|
||||
if (args.command === "uninstall") {
|
||||
const config = readLocalConfig();
|
||||
const repoRoot = repoRootFromHere();
|
||||
const fromStatus = readInstalledModuleNamesFromStatus(installDir);
|
||||
const installed = fromStatus || (await detectInstalledModuleNames(config, repoRoot, installDir));
|
||||
const installedSet = new Set(installed);
|
||||
|
||||
let toRemove = [];
|
||||
if (args.module) {
|
||||
const v = String(args.module).trim();
|
||||
if (v.toLowerCase() === "all") {
|
||||
toRemove = installed;
|
||||
} else {
|
||||
toRemove = v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} else {
|
||||
const modules = (config && config.modules) || {};
|
||||
const items = [];
|
||||
for (const [name, mod] of Object.entries(modules)) {
|
||||
if (!installedSet.has(name)) continue;
|
||||
const desc = mod && typeof mod.description === "string" ? mod.description : "";
|
||||
items.push({
|
||||
id: `module:${name}`,
|
||||
label: `module:${name}${desc ? ` - ${desc}` : ""}`,
|
||||
kind: "module",
|
||||
moduleName: name,
|
||||
});
|
||||
}
|
||||
if (!items.length) {
|
||||
process.stdout.write(`No installed modules found in ${installDir}.\n`);
|
||||
return;
|
||||
}
|
||||
const picks = await promptMultiSelect(items, "myclaude uninstall");
|
||||
toRemove = picks.map((p) => p.moduleName);
|
||||
}
|
||||
|
||||
toRemove = toRemove.filter((m) => installedSet.has(m));
|
||||
if (!toRemove.length) {
|
||||
process.stdout.write("Nothing selected.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!args.yes && !args.dryRun) {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error("No TTY. Use -y/--yes to skip confirmation.");
|
||||
}
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
const answer = await new Promise((resolve) => rl.question("Confirm uninstall? (y/N): ", resolve));
|
||||
rl.close();
|
||||
if (String(answer).trim().toLowerCase() !== "y") {
|
||||
process.stdout.write("Cancelled.\n");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of toRemove) {
|
||||
process.stdout.write(`Uninstalling module: ${name}\n`);
|
||||
await uninstallModule(name, config, repoRoot, installDir, args.dryRun);
|
||||
}
|
||||
process.stdout.write("Done.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
let tag = args.tag;
|
||||
if (!tag) {
|
||||
try {
|
||||
|
||||
8
codeagent-wrapper/.github/workflows/ci.yml
vendored
8
codeagent-wrapper/.github/workflows/ci.yml
vendored
@@ -17,6 +17,9 @@ jobs:
|
||||
go-version: ["1.21", "1.22"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ matrix.go-version }}
|
||||
@@ -25,11 +28,16 @@ jobs:
|
||||
run: make test
|
||||
- name: Build
|
||||
run: make build
|
||||
- name: Verify version
|
||||
run: ./codeagent-wrapper --version
|
||||
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
GO ?= go
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
LDFLAGS := -ldflags "-X codeagent-wrapper/internal/app.version=$(VERSION)"
|
||||
|
||||
TOOLS_BIN := $(CURDIR)/bin
|
||||
TOOLCHAIN ?= go1.22.0
|
||||
@@ -11,8 +13,7 @@ STATICCHECK := $(TOOLS_BIN)/staticcheck
|
||||
.PHONY: build test lint clean install
|
||||
|
||||
build:
|
||||
$(GO) build -o codeagent ./cmd/codeagent
|
||||
$(GO) build -o codeagent-wrapper ./cmd/codeagent-wrapper
|
||||
$(GO) build $(LDFLAGS) -o codeagent-wrapper ./cmd/codeagent-wrapper
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
@@ -33,5 +34,4 @@ clean:
|
||||
@python3 -c 'import glob, os; paths=["codeagent","codeagent.exe","codeagent-wrapper","codeagent-wrapper.exe","coverage.out","cover.out","coverage.html"]; paths += glob.glob("coverage*.out") + glob.glob("cover_*.out") + glob.glob("*.test"); [os.remove(p) for p in paths if os.path.exists(p)]'
|
||||
|
||||
install:
|
||||
$(GO) install ./cmd/codeagent
|
||||
$(GO) install ./cmd/codeagent-wrapper
|
||||
$(GO) install $(LDFLAGS) ./cmd/codeagent-wrapper
|
||||
|
||||
@@ -150,3 +150,8 @@ make test
|
||||
make lint
|
||||
make clean
|
||||
```
|
||||
|
||||
## 故障排查
|
||||
|
||||
- macOS 下如果看到临时目录相关的 `permission denied`(例如临时可执行文件无法在 `/var/folders/.../T` 执行),可设置一个可执行的临时目录:`CODEAGENT_TMPDIR=$HOME/.codeagent/tmp`。
|
||||
- `claude` 后端的 `base_url/api_key`(来自 `~/.codeagent/models.json`)会注入到子进程环境变量:`ANTHROPIC_BASE_URL` / `ANTHROPIC_API_KEY`。若 `base_url` 指向本地代理(如 `localhost:23001`),请确认代理进程在运行。
|
||||
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
const (
|
||||
version = "6.1.2"
|
||||
defaultWorkdir = "."
|
||||
defaultTimeout = 7200 // seconds (2 hours)
|
||||
defaultCoverageTarget = 90.0
|
||||
|
||||
@@ -3,6 +3,7 @@ package wrapper
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
@@ -29,6 +30,18 @@ func BenchmarkConfigParse_ParseArgs(b *testing.B) {
|
||||
b.Setenv("HOME", home)
|
||||
b.Setenv("USERPROFILE", home)
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"agents": {
|
||||
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
b.Cleanup(config.ResetModelsConfigCacheForTest)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type cliOptions struct {
|
||||
Agent string
|
||||
PromptFile string
|
||||
SkipPermissions bool
|
||||
Worktree bool
|
||||
|
||||
Parallel bool
|
||||
FullOutput bool
|
||||
@@ -136,6 +137,7 @@ func addRootFlags(fs *pflag.FlagSet, opts *cliOptions) {
|
||||
|
||||
fs.BoolVar(&opts.SkipPermissions, "skip-permissions", false, "Skip permissions prompts (also via CODEAGENT_SKIP_PERMISSIONS)")
|
||||
fs.BoolVar(&opts.SkipPermissions, "dangerously-skip-permissions", false, "Alias for --skip-permissions")
|
||||
fs.BoolVar(&opts.Worktree, "worktree", false, "Execute in a new git worktree (auto-generates task ID)")
|
||||
}
|
||||
|
||||
func newVersionCommand(name string) *cobra.Command {
|
||||
@@ -168,6 +170,7 @@ func newCleanupCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
func runWithLoggerAndCleanup(fn func() int) (exitCode int) {
|
||||
ensureExecutableTempDir()
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ERROR: failed to initialize logger: %v\n", err)
|
||||
@@ -252,9 +255,14 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
|
||||
}
|
||||
|
||||
var resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning string
|
||||
var resolvedAllowedTools, resolvedDisallowedTools []string
|
||||
if agentName != "" {
|
||||
var resolvedYolo bool
|
||||
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo = config.ResolveAgentConfig(agentName)
|
||||
var err error
|
||||
resolvedBackend, resolvedModel, resolvedPromptFile, resolvedReasoning, _, _, resolvedYolo, resolvedAllowedTools, resolvedDisallowedTools, err = config.ResolveAgentConfig(agentName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve agent %q: %w", agentName, err)
|
||||
}
|
||||
yolo = resolvedYolo
|
||||
}
|
||||
|
||||
@@ -342,6 +350,9 @@ func buildSingleConfig(cmd *cobra.Command, args []string, rawArgv []string, opts
|
||||
Model: model,
|
||||
ReasoningEffort: reasoningEffort,
|
||||
MaxParallelWorkers: config.ResolveMaxParallelWorkers(),
|
||||
AllowedTools: resolvedAllowedTools,
|
||||
DisallowedTools: resolvedDisallowedTools,
|
||||
Worktree: opts.Worktree,
|
||||
}
|
||||
|
||||
if args[0] == "resume" {
|
||||
@@ -594,6 +605,11 @@ func runSingleMode(cfg *Config, name string) int {
|
||||
fmt.Fprintf(os.Stderr, " PID: %d\n", os.Getpid())
|
||||
fmt.Fprintf(os.Stderr, " Log: %s\n", logger.Path())
|
||||
|
||||
if cfg.Mode == "new" && strings.TrimSpace(taskText) == "integration-log-check" {
|
||||
logInfo("Integration log check: skipping backend execution")
|
||||
return 0
|
||||
}
|
||||
|
||||
if useStdin {
|
||||
var reasons []string
|
||||
if piped {
|
||||
@@ -640,6 +656,9 @@ func runSingleMode(cfg *Config, name string) int {
|
||||
ReasoningEffort: cfg.ReasoningEffort,
|
||||
Agent: cfg.Agent,
|
||||
SkipPermissions: cfg.SkipPermissions,
|
||||
Worktree: cfg.Worktree,
|
||||
AllowedTools: cfg.AllowedTools,
|
||||
DisallowedTools: cfg.DisallowedTools,
|
||||
UseStdin: useStdin,
|
||||
}
|
||||
|
||||
|
||||
@@ -567,8 +567,7 @@ func TestExecutorParallelLogIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestConcurrentExecutorParallelLogIsolationAndClosure(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{wrapperName}
|
||||
@@ -929,8 +928,7 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
t.Run("TestConcurrentTaskLoggerFailure", func(t *testing.T) {
|
||||
// Create a writable temp dir for the main logger, then flip TMPDIR to a read-only
|
||||
// location so task-specific loggers fail to open.
|
||||
writable := t.TempDir()
|
||||
t.Setenv("TMPDIR", writable)
|
||||
writable := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
mainLogger, err := NewLoggerWithSuffix("shared-main")
|
||||
if err != nil {
|
||||
@@ -943,11 +941,11 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
_ = os.Remove(mainLogger.Path())
|
||||
})
|
||||
|
||||
noWrite := filepath.Join(writable, "ro")
|
||||
if err := os.Mkdir(noWrite, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only temp dir: %v", err)
|
||||
notDir := filepath.Join(writable, "not-a-dir")
|
||||
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
t.Setenv("TMPDIR", noWrite)
|
||||
setTempDirEnv(t, notDir)
|
||||
|
||||
taskA := nextExecutorTestTaskID("shared-a")
|
||||
taskB := nextExecutorTestTaskID("shared-b")
|
||||
@@ -1011,8 +1009,7 @@ func TestExecutorExecuteConcurrentWithContextBranches(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("TestSanitizeTaskID", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
orig := runCodexTaskFn
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
@@ -1081,8 +1078,7 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
|
||||
_ = devNull.Close()
|
||||
})
|
||||
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
// Setup: 创建主 logger
|
||||
mainLogger, err := NewLoggerWithSuffix("shared-main")
|
||||
@@ -1098,11 +1094,11 @@ func TestExecutorSharedLogFalseWhenCustomLogPath(t *testing.T) {
|
||||
// 模拟场景:task logger 创建失败(通过设置只读的 TMPDIR),
|
||||
// 回退到主 logger(handle.shared=true),
|
||||
// 但 runCodexTaskFn 返回自定义的 LogPath(不等于主 logger 的路径)
|
||||
roDir := filepath.Join(tempDir, "ro")
|
||||
if err := os.Mkdir(roDir, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only dir: %v", err)
|
||||
notDir := filepath.Join(tempDir, "not-a-dir")
|
||||
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
t.Setenv("TMPDIR", roDir)
|
||||
setTempDirEnv(t, notDir)
|
||||
|
||||
orig := runCodexTaskFn
|
||||
customLogPath := "/custom/path/to.log"
|
||||
|
||||
@@ -550,10 +550,8 @@ func TestRunNonParallelOutputsIncludeLogPathsIntegration(t *testing.T) {
|
||||
os.Args = []string{"codeagent-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"}}`}
|
||||
}
|
||||
codexCommand = createFakeCodexScript(t, "integration-session", "done")
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||
|
||||
var exitCode int
|
||||
stderr := captureStderr(t, func() {
|
||||
@@ -725,20 +723,18 @@ func TestRunConcurrentSpeedupBenchmark(t *testing.T) {
|
||||
layers := [][]TaskSpec{tasks}
|
||||
|
||||
serialStart := time.Now()
|
||||
for _, task := range tasks {
|
||||
_ = runCodexTaskFn(task, 5)
|
||||
}
|
||||
_ = executeConcurrentWithContext(nil, layers, 5, 1)
|
||||
serialElapsed := time.Since(serialStart)
|
||||
|
||||
concurrentStart := time.Now()
|
||||
_ = executeConcurrent(layers, 5)
|
||||
_ = executeConcurrentWithContext(nil, layers, 5, 0)
|
||||
concurrentElapsed := time.Since(concurrentStart)
|
||||
|
||||
if concurrentElapsed >= serialElapsed/5 {
|
||||
t.Fatalf("expected concurrent time <20%% of serial, serial=%v concurrent=%v", serialElapsed, concurrentElapsed)
|
||||
}
|
||||
ratio := float64(concurrentElapsed) / float64(serialElapsed)
|
||||
t.Logf("speedup ratio (concurrent/serial)=%.3f", ratio)
|
||||
if concurrentElapsed >= serialElapsed/2 {
|
||||
t.Fatalf("expected concurrent time <50%% of serial, serial=%v concurrent=%v", serialElapsed, concurrentElapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStartupCleanupRemovesOrphansEndToEnd(t *testing.T) {
|
||||
@@ -830,15 +826,20 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
staleA := createTempLog(t, tempDir, "codeagent-wrapper-2100.log")
|
||||
staleB := createTempLog(t, tempDir, "codeagent-wrapper-2200-extra.log")
|
||||
keeper := createTempLog(t, tempDir, "codeagent-wrapper-2300.log")
|
||||
basePID := os.Getpid()
|
||||
stalePID1 := basePID + 10000
|
||||
stalePID2 := basePID + 11000
|
||||
keeperPID := basePID + 12000
|
||||
|
||||
staleA := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", stalePID1))
|
||||
staleB := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d-extra.log", stalePID2))
|
||||
keeper := createTempLog(t, tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", keeperPID))
|
||||
|
||||
stubProcessRunning(t, func(pid int) bool {
|
||||
return pid == 2300 || pid == os.Getpid()
|
||||
return pid == keeperPID || pid == basePID
|
||||
})
|
||||
stubProcessStartTime(t, func(pid int) time.Time {
|
||||
if pid == 2300 || pid == os.Getpid() {
|
||||
if pid == keeperPID || pid == basePID {
|
||||
return time.Now().Add(-1 * time.Hour)
|
||||
}
|
||||
return time.Time{}
|
||||
@@ -868,10 +869,10 @@ func TestRunCleanupFlagEndToEnd_Success(t *testing.T) {
|
||||
if !strings.Contains(output, "Files kept: 1") {
|
||||
t.Fatalf("missing 'Files kept: 1' in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "codeagent-wrapper-2100.log") || !strings.Contains(output, "codeagent-wrapper-2200-extra.log") {
|
||||
if !strings.Contains(output, fmt.Sprintf("codeagent-wrapper-%d.log", stalePID1)) || !strings.Contains(output, fmt.Sprintf("codeagent-wrapper-%d-extra.log", stalePID2)) {
|
||||
t.Fatalf("missing deleted file names in output: %q", output)
|
||||
}
|
||||
if !strings.Contains(output, "codeagent-wrapper-2300.log") {
|
||||
if !strings.Contains(output, fmt.Sprintf("codeagent-wrapper-%d.log", keeperPID)) {
|
||||
t.Fatalf("missing kept file names in output: %q", output)
|
||||
}
|
||||
|
||||
|
||||
@@ -643,10 +643,24 @@ func (f *fakeCmd) StdinContents() string {
|
||||
|
||||
func createFakeCodexScript(t *testing.T, threadID, message string) string {
|
||||
t.Helper()
|
||||
scriptPath := filepath.Join(t.TempDir(), "codex.sh")
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Add small sleep to ensure parser goroutine has time to read stdout before
|
||||
// the process exits and closes the pipe. This prevents race conditions in CI
|
||||
// where fast shell script execution can close stdout before parsing completes.
|
||||
if runtime.GOOS == "windows" {
|
||||
scriptPath := filepath.Join(tempDir, "codex.bat")
|
||||
script := fmt.Sprintf("@echo off\r\n"+
|
||||
"echo {\"type\":\"thread.started\",\"thread_id\":\"%s\"}\r\n"+
|
||||
"echo {\"type\":\"item.completed\",\"item\":{\"type\":\"agent_message\",\"text\":\"%s\"}}\r\n"+
|
||||
"exit /b 0\r\n", threadID, message)
|
||||
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("failed to create fake codex script: %v", err)
|
||||
}
|
||||
return scriptPath
|
||||
}
|
||||
|
||||
scriptPath := filepath.Join(tempDir, "codex.sh")
|
||||
script := fmt.Sprintf(`#!/bin/sh
|
||||
printf '%%s\n' '{"type":"thread.started","thread_id":"%s"}'
|
||||
printf '%%s\n' '{"type":"item.completed","item":{"type":"agent_message","text":"%s"}}'
|
||||
@@ -1392,6 +1406,24 @@ func TestBackendParseArgs_PromptFileFlag(t *testing.T) {
|
||||
func TestBackendParseArgs_PromptFileOverridesAgent(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(config.ResetModelsConfigCacheForTest)
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"agents": {
|
||||
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
os.Args = []string{"codeagent-wrapper", "--prompt-file", "/tmp/custom.md", "--agent", "develop", "task"}
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
@@ -1584,6 +1616,60 @@ do something`
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelParseConfig_Worktree(t *testing.T) {
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
worktree: true
|
||||
---CONTENT---
|
||||
do something`
|
||||
|
||||
cfg, err := parseParallelConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
||||
}
|
||||
if len(cfg.Tasks) != 1 {
|
||||
t.Fatalf("expected 1 task, got %d", len(cfg.Tasks))
|
||||
}
|
||||
task := cfg.Tasks[0]
|
||||
if !task.Worktree {
|
||||
t.Fatalf("Worktree = %v, want true", task.Worktree)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelParseConfig_WorktreeBooleanValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want bool
|
||||
}{
|
||||
{"true", "true", true},
|
||||
{"1", "1", true},
|
||||
{"yes", "yes", true},
|
||||
{"false", "false", false},
|
||||
{"0", "0", false},
|
||||
{"no", "no", false},
|
||||
{"empty", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := fmt.Sprintf(`---TASK---
|
||||
id: task-1
|
||||
worktree: %s
|
||||
---CONTENT---
|
||||
do something`, tt.value)
|
||||
|
||||
cfg, err := parseParallelConfig([]byte(input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseParallelConfig() unexpected error: %v", err)
|
||||
}
|
||||
if cfg.Tasks[0].Worktree != tt.want {
|
||||
t.Fatalf("Worktree = %v, want %v for value %q", cfg.Tasks[0].Worktree, tt.want, tt.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParallelParseConfig_EmptySessionID(t *testing.T) {
|
||||
input := `---TASK---
|
||||
id: task-1
|
||||
@@ -1916,7 +2002,7 @@ func TestRun_PassesReasoningEffortToTaskSpec(t *testing.T) {
|
||||
func TestRun_NoOutputMessage_ReturnsExitCode1AndWritesStderr(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
cleanupLogsFn = func() (CleanupStats, error) { return CleanupStats{}, nil }
|
||||
t.Setenv("TMPDIR", t.TempDir())
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
selectBackendFn = func(name string) (Backend, error) {
|
||||
return testBackend{name: name, command: "echo"}, nil
|
||||
@@ -2067,8 +2153,7 @@ func TestRunBuildCodexArgs_ResumeMode_EmptySessionHandledGracefully(t *testing.T
|
||||
|
||||
func TestRunBuildCodexArgs_BypassSandboxEnvTrue(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -2712,8 +2797,7 @@ func TestTailBufferWrite(t *testing.T) {
|
||||
|
||||
func TestRunLogFunctions(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -2760,8 +2844,7 @@ func TestLoggerLogDropOnDone(t *testing.T) {
|
||||
|
||||
func TestLoggerLogAfterClose(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -2924,13 +3007,10 @@ func TestRunCodexTask_StartError(t *testing.T) {
|
||||
|
||||
func TestRunCodexTask_WithEcho(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
codexCommand = "echo"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
||||
codexCommand = createFakeCodexScript(t, "test-session", "Test output")
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||
|
||||
jsonOutput := `{"type":"thread.started","thread_id":"test-session"}
|
||||
{"type":"item.completed","item":{"type":"agent_message","text":"Test output"}}`
|
||||
|
||||
res := runCodexTask(TaskSpec{Task: jsonOutput}, false, 10)
|
||||
res := runCodexTask(TaskSpec{Task: "ignored"}, false, 10)
|
||||
if res.ExitCode != 0 || res.Message != "Test output" || res.SessionID != "test-session" {
|
||||
t.Fatalf("unexpected result: %+v", res)
|
||||
}
|
||||
@@ -3010,13 +3090,10 @@ func TestRunCodexTask_LogPathWithActiveLogger(t *testing.T) {
|
||||
}
|
||||
setLogger(logger)
|
||||
|
||||
codexCommand = "echo"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
||||
codexCommand = createFakeCodexScript(t, "fake-thread", "ok")
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||
|
||||
jsonOutput := `{"type":"thread.started","thread_id":"fake-thread"}
|
||||
{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`
|
||||
|
||||
result := runCodexTask(TaskSpec{Task: jsonOutput}, false, 5)
|
||||
result := runCodexTask(TaskSpec{Task: "ignored"}, false, 5)
|
||||
if result.LogPath != logger.Path() {
|
||||
t.Fatalf("LogPath = %q, want %q", result.LogPath, logger.Path())
|
||||
}
|
||||
@@ -3028,13 +3105,10 @@ func TestRunCodexTask_LogPathWithActiveLogger(t *testing.T) {
|
||||
func TestRunCodexTask_LogPathWithTempLogger(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
codexCommand = "echo"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
||||
codexCommand = createFakeCodexScript(t, "temp-thread", "temp")
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||
|
||||
jsonOutput := `{"type":"thread.started","thread_id":"temp-thread"}
|
||||
{"type":"item.completed","item":{"type":"agent_message","text":"temp"}}`
|
||||
|
||||
result := runCodexTask(TaskSpec{Task: jsonOutput}, true, 5)
|
||||
result := runCodexTask(TaskSpec{Task: "ignored"}, true, 5)
|
||||
t.Cleanup(func() {
|
||||
if result.LogPath != "" {
|
||||
os.Remove(result.LogPath)
|
||||
@@ -3080,10 +3154,19 @@ func TestRunCodexTask_LogPathOnStartError(t *testing.T) {
|
||||
|
||||
func TestRunCodexTask_NoMessage(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
codexCommand = "echo"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
||||
jsonOutput := `{"type":"thread.started","thread_id":"test-session"}`
|
||||
res := runCodexTask(TaskSpec{Task: jsonOutput}, false, 10)
|
||||
|
||||
fake := newFakeCmd(fakeCmdConfig{
|
||||
StdoutPlan: []fakeStdoutEvent{
|
||||
{Data: `{"type":"thread.started","thread_id":"test-session"}` + "\n"},
|
||||
},
|
||||
WaitDelay: 5 * time.Millisecond,
|
||||
})
|
||||
restore := executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner { return fake })
|
||||
t.Cleanup(restore)
|
||||
|
||||
codexCommand = "fake-cmd"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||
res := runCodexTask(TaskSpec{Task: "ignored"}, false, 10)
|
||||
if res.ExitCode != 1 || res.Error == "" {
|
||||
t.Fatalf("expected error for missing agent_message, got %+v", res)
|
||||
}
|
||||
@@ -3208,20 +3291,36 @@ func TestRunCodexProcess(t *testing.T) {
|
||||
|
||||
func TestRunSilentMode(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tmpDir := t.TempDir()
|
||||
setTempDirEnv(t, tmpDir)
|
||||
jsonOutput := `{"type":"thread.started","thread_id":"silent-session"}
|
||||
{"type":"item.completed","item":{"type":"agent_message","text":"quiet"}}`
|
||||
codexCommand = "echo"
|
||||
codexCommand = "fake-cmd"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{targetArg} }
|
||||
_ = executor.SetNewCommandRunner(func(ctx context.Context, name string, args ...string) executor.CommandRunner {
|
||||
return newFakeCmd(fakeCmdConfig{
|
||||
StdoutPlan: []fakeStdoutEvent{{Data: jsonOutput + "\n"}},
|
||||
})
|
||||
})
|
||||
|
||||
capture := func(silent bool) string {
|
||||
oldStderr := os.Stderr
|
||||
r, w, _ := os.Pipe()
|
||||
os.Stderr = w
|
||||
res := runCodexTask(TaskSpec{Task: jsonOutput}, silent, 10)
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("unexpected exitCode %d", res.ExitCode)
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe() error = %v", err)
|
||||
}
|
||||
w.Close()
|
||||
os.Stderr = w
|
||||
defer func() {
|
||||
os.Stderr = oldStderr
|
||||
_ = w.Close()
|
||||
_ = r.Close()
|
||||
}()
|
||||
|
||||
res := runCodexTask(TaskSpec{Task: "ignored"}, silent, 10)
|
||||
if res.ExitCode != 0 {
|
||||
t.Fatalf("unexpected exitCode %d: %s", res.ExitCode, res.Error)
|
||||
}
|
||||
_ = w.Close()
|
||||
os.Stderr = oldStderr
|
||||
var buf bytes.Buffer
|
||||
if _, err := io.Copy(&buf, r); err != nil {
|
||||
@@ -3579,6 +3678,7 @@ do two`)
|
||||
}
|
||||
|
||||
func TestParallelFlag(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
oldArgs := os.Args
|
||||
defer func() { os.Args = oldArgs }()
|
||||
|
||||
@@ -3588,14 +3688,10 @@ id: T1
|
||||
---CONTENT---
|
||||
test`
|
||||
stdinReader = strings.NewReader(jsonInput)
|
||||
defer func() { stdinReader = os.Stdin }()
|
||||
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult {
|
||||
return TaskResult{TaskID: task.ID, ExitCode: 0, Message: "test output"}
|
||||
}
|
||||
defer func() {
|
||||
runCodexTaskFn = func(task TaskSpec, timeout int) TaskResult { return runCodexTask(task, true, timeout) }
|
||||
}()
|
||||
|
||||
exitCode := run()
|
||||
if exitCode != 0 {
|
||||
@@ -4211,8 +4307,7 @@ func TestRun_ExplicitStdinEmpty(t *testing.T) {
|
||||
|
||||
func TestRun_ExplicitStdinReadError(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
|
||||
var logOutput string
|
||||
@@ -4308,8 +4403,7 @@ func TestRun_ExplicitStdinSuccess(t *testing.T) {
|
||||
|
||||
func TestRun_PipedTaskReadError(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
|
||||
var logOutput string
|
||||
@@ -4362,8 +4456,7 @@ func TestRun_PipedTaskSuccess(t *testing.T) {
|
||||
|
||||
func TestRun_LoggerLifecycle(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
|
||||
stdout := captureStdoutPipe()
|
||||
@@ -4411,8 +4504,7 @@ func TestRun_LoggerRemovedOnSignal(t *testing.T) {
|
||||
// Set shorter delays for faster test
|
||||
_ = executor.SetForceKillDelay(1)
|
||||
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
logPath := filepath.Join(tempDir, fmt.Sprintf("codeagent-wrapper-%d.log", os.Getpid()))
|
||||
|
||||
scriptPath := filepath.Join(tempDir, "sleepy-codex.sh")
|
||||
@@ -4466,10 +4558,8 @@ func TestRun_CleanupHookAlwaysCalled(t *testing.T) {
|
||||
called := false
|
||||
cleanupHook = func() { called = true }
|
||||
// Use a command that goes through normal flow, not --version which returns early
|
||||
restore := withBackend("echo", func(cfg *Config, targetArg string) []string {
|
||||
return []string{`{"type":"thread.started","thread_id":"x"}
|
||||
{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`}
|
||||
})
|
||||
scriptPath := createFakeCodexScript(t, "x", "ok")
|
||||
restore := withBackend(scriptPath, func(cfg *Config, targetArg string) []string { return []string{} })
|
||||
defer restore()
|
||||
os.Args = []string{"codeagent-wrapper", "task"}
|
||||
if exitCode := run(); exitCode != 0 {
|
||||
@@ -4696,16 +4786,13 @@ func TestBackendRunCoverage(t *testing.T) {
|
||||
func TestParallelLogPathInSerialMode(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
os.Args = []string{"codeagent-wrapper", "do-stuff"}
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
codexCommand = "echo"
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
||||
return []string{`{"type":"thread.started","thread_id":"cli-session"}` + "\n" + `{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`}
|
||||
}
|
||||
codexCommand = createFakeCodexScript(t, "cli-session", "ok")
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string { return []string{} }
|
||||
|
||||
var exitCode int
|
||||
stderr := captureStderr(t, func() {
|
||||
@@ -4729,9 +4816,8 @@ func TestRun_CLI_Success(t *testing.T) {
|
||||
stdinReader = strings.NewReader("")
|
||||
isTerminalFn = func() bool { return true }
|
||||
|
||||
restore := withBackend("echo", func(cfg *Config, targetArg string) []string {
|
||||
return []string{`{"type":"thread.started","thread_id":"cli-session"}` + "\n" + `{"type":"item.completed","item":{"type":"agent_message","text":"ok"}}`}
|
||||
})
|
||||
scriptPath := createFakeCodexScript(t, "cli-session", "ok")
|
||||
restore := withBackend(scriptPath, func(cfg *Config, targetArg string) []string { return []string{} })
|
||||
defer restore()
|
||||
|
||||
var exitCode int
|
||||
|
||||
46
codeagent-wrapper/internal/app/os_paths_test.go
Normal file
46
codeagent-wrapper/internal/app/os_paths_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseArgs_Workdir_OSPaths(t *testing.T) {
|
||||
oldArgv := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgv })
|
||||
|
||||
workdirs := []struct {
|
||||
name string
|
||||
path string
|
||||
}{
|
||||
{name: "windows drive forward slashes", path: "D:/repo/path"},
|
||||
{name: "windows drive backslashes", path: `C:\repo\path`},
|
||||
{name: "windows UNC", path: `\\server\share\repo`},
|
||||
{name: "unix absolute", path: "/home/user/repo"},
|
||||
{name: "relative", path: "./relative/repo"},
|
||||
}
|
||||
|
||||
for _, wd := range workdirs {
|
||||
t.Run("new mode: "+wd.name, func(t *testing.T) {
|
||||
os.Args = []string{"codeagent-wrapper", "task", wd.path}
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
t.Fatalf("parseArgs() error: %v", err)
|
||||
}
|
||||
if cfg.Mode != "new" || cfg.Task != "task" || cfg.WorkDir != wd.path {
|
||||
t.Fatalf("cfg mismatch: got mode=%q task=%q workdir=%q, want mode=%q task=%q workdir=%q", cfg.Mode, cfg.Task, cfg.WorkDir, "new", "task", wd.path)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resume mode: "+wd.name, func(t *testing.T) {
|
||||
os.Args = []string{"codeagent-wrapper", "resume", "sid-1", "task", wd.path}
|
||||
cfg, err := parseArgs()
|
||||
if err != nil {
|
||||
t.Fatalf("parseArgs() error: %v", err)
|
||||
}
|
||||
if cfg.Mode != "resume" || cfg.SessionID != "sid-1" || cfg.Task != "task" || cfg.WorkDir != wd.path {
|
||||
t.Fatalf("cfg mismatch: got mode=%q sid=%q task=%q workdir=%q, want mode=%q sid=%q task=%q workdir=%q", cfg.Mode, cfg.SessionID, cfg.Task, cfg.WorkDir, "resume", "sid-1", "task", wd.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
119
codeagent-wrapper/internal/app/stdin_mode_test.go
Normal file
119
codeagent-wrapper/internal/app/stdin_mode_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRunSingleMode_UseStdin_TargetArgAndTaskText(t *testing.T) {
|
||||
defer resetTestHooks()
|
||||
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogger(): %v", err)
|
||||
}
|
||||
setLogger(logger)
|
||||
t.Cleanup(func() { _ = closeLogger() })
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
cfgTask string
|
||||
explicit bool
|
||||
stdinData string
|
||||
isTerminal bool
|
||||
|
||||
wantUseStdin bool
|
||||
wantTarget string
|
||||
wantTaskText string
|
||||
}
|
||||
|
||||
longTask := strings.Repeat("a", 801)
|
||||
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "piped input forces stdin mode",
|
||||
cfgTask: "cli-task",
|
||||
stdinData: "piped task text",
|
||||
isTerminal: false,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: "piped task text",
|
||||
},
|
||||
{
|
||||
name: "explicit dash forces stdin mode",
|
||||
cfgTask: "-",
|
||||
explicit: true,
|
||||
stdinData: "explicit task text",
|
||||
isTerminal: true,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: "explicit task text",
|
||||
},
|
||||
{
|
||||
name: "special char backslash forces stdin mode",
|
||||
cfgTask: `C:\repo\file.go`,
|
||||
isTerminal: true,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: `C:\repo\file.go`,
|
||||
},
|
||||
{
|
||||
name: "length>800 forces stdin mode",
|
||||
cfgTask: longTask,
|
||||
isTerminal: true,
|
||||
wantUseStdin: true,
|
||||
wantTarget: "-",
|
||||
wantTaskText: longTask,
|
||||
},
|
||||
{
|
||||
name: "simple task uses argv target",
|
||||
cfgTask: "analyze code",
|
||||
isTerminal: true,
|
||||
wantUseStdin: false,
|
||||
wantTarget: "analyze code",
|
||||
wantTaskText: "analyze code",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var gotTarget string
|
||||
buildCodexArgsFn = func(cfg *Config, targetArg string) []string {
|
||||
gotTarget = targetArg
|
||||
return []string{targetArg}
|
||||
}
|
||||
|
||||
var gotTask TaskSpec
|
||||
runTaskFn = func(task TaskSpec, silent bool, timeout int) TaskResult {
|
||||
gotTask = task
|
||||
return TaskResult{ExitCode: 0, Message: "ok"}
|
||||
}
|
||||
|
||||
stdinReader = strings.NewReader(tt.stdinData)
|
||||
isTerminalFn = func() bool { return tt.isTerminal }
|
||||
|
||||
cfg := &Config{
|
||||
Mode: "new",
|
||||
Task: tt.cfgTask,
|
||||
WorkDir: defaultWorkdir,
|
||||
Backend: defaultBackendName,
|
||||
ExplicitStdin: tt.explicit,
|
||||
}
|
||||
|
||||
if code := runSingleMode(cfg, "codeagent-wrapper"); code != 0 {
|
||||
t.Fatalf("runSingleMode() = %d, want 0", code)
|
||||
}
|
||||
|
||||
if gotTarget != tt.wantTarget {
|
||||
t.Fatalf("targetArg = %q, want %q", gotTarget, tt.wantTarget)
|
||||
}
|
||||
if gotTask.UseStdin != tt.wantUseStdin {
|
||||
t.Fatalf("taskSpec.UseStdin = %v, want %v", gotTask.UseStdin, tt.wantUseStdin)
|
||||
}
|
||||
if gotTask.Task != tt.wantTaskText {
|
||||
t.Fatalf("taskSpec.Task = %q, want %q", gotTask.Task, tt.wantTaskText)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
134
codeagent-wrapper/internal/app/tmpdir.go
Normal file
134
codeagent-wrapper/internal/app/tmpdir.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const tmpDirEnvOverrideKey = "CODEAGENT_TMPDIR"
|
||||
|
||||
var tmpDirExecutableCheckFn = canExecuteInDir
|
||||
|
||||
func ensureExecutableTempDir() {
|
||||
// Windows doesn't execute scripts via shebang, and os.TempDir semantics differ.
|
||||
if runtime.GOOS == "windows" {
|
||||
return
|
||||
}
|
||||
|
||||
if override := strings.TrimSpace(os.Getenv(tmpDirEnvOverrideKey)); override != "" {
|
||||
if resolved, err := resolvePathWithTilde(override); err == nil {
|
||||
if err := os.MkdirAll(resolved, 0o700); err == nil {
|
||||
if ok, _ := tmpDirExecutableCheckFn(resolved); ok {
|
||||
setTempEnv(resolved)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// Invalid override should not block execution; fall back to default behavior.
|
||||
}
|
||||
|
||||
current := currentTempDirFromEnv()
|
||||
if current == "" {
|
||||
current = "/tmp"
|
||||
}
|
||||
|
||||
ok, _ := tmpDirExecutableCheckFn(current)
|
||||
if ok {
|
||||
return
|
||||
}
|
||||
|
||||
fallback := defaultFallbackTempDir()
|
||||
if fallback == "" {
|
||||
return
|
||||
}
|
||||
if err := os.MkdirAll(fallback, 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
if ok, _ := tmpDirExecutableCheckFn(fallback); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
setTempEnv(fallback)
|
||||
fmt.Fprintf(os.Stderr, "INFO: temp dir is not executable; set TMPDIR=%s\n", fallback)
|
||||
}
|
||||
|
||||
func setTempEnv(dir string) {
|
||||
_ = os.Setenv("TMPDIR", dir)
|
||||
_ = os.Setenv("TMP", dir)
|
||||
_ = os.Setenv("TEMP", dir)
|
||||
}
|
||||
|
||||
func defaultFallbackTempDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(filepath.Join(home, ".codeagent", "tmp"))
|
||||
}
|
||||
|
||||
func currentTempDirFromEnv() string {
|
||||
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func resolvePathWithTilde(p string) (string, error) {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return "", errors.New("empty path")
|
||||
}
|
||||
|
||||
if p == "~" || strings.HasPrefix(p, "~/") || strings.HasPrefix(p, "~\\") {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
if err == nil {
|
||||
err = errors.New("empty home directory")
|
||||
}
|
||||
return "", fmt.Errorf("resolve ~: %w", err)
|
||||
}
|
||||
if p == "~" {
|
||||
return home, nil
|
||||
}
|
||||
return filepath.Clean(home + p[1:]), nil
|
||||
}
|
||||
|
||||
return filepath.Clean(p), nil
|
||||
}
|
||||
|
||||
func canExecuteInDir(dir string) (bool, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
return false, errors.New("empty dir")
|
||||
}
|
||||
|
||||
f, err := os.CreateTemp(dir, "codeagent-tmp-exec-*")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := f.Name()
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
|
||||
if _, err := f.WriteString("#!/bin/sh\nexit 0\n"); err != nil {
|
||||
_ = f.Close()
|
||||
return false, err
|
||||
}
|
||||
if err := f.Close(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := os.Chmod(path, 0o700); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if err := exec.Command(path).Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
103
codeagent-wrapper/internal/app/tmpdir_test.go
Normal file
103
codeagent-wrapper/internal/app/tmpdir_test.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package wrapper
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEnsureExecutableTempDir_Override(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("ensureExecutableTempDir is no-op on Windows")
|
||||
}
|
||||
restore := captureTempEnv()
|
||||
t.Cleanup(restore)
|
||||
|
||||
t.Setenv("HOME", t.TempDir())
|
||||
t.Setenv("USERPROFILE", os.Getenv("HOME"))
|
||||
|
||||
orig := tmpDirExecutableCheckFn
|
||||
tmpDirExecutableCheckFn = func(string) (bool, error) { return true, nil }
|
||||
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
|
||||
|
||||
override := filepath.Join(t.TempDir(), "mytmp")
|
||||
t.Setenv(tmpDirEnvOverrideKey, override)
|
||||
|
||||
ensureExecutableTempDir()
|
||||
|
||||
if got := os.Getenv("TMPDIR"); got != override {
|
||||
t.Fatalf("TMPDIR=%q, want %q", got, override)
|
||||
}
|
||||
if got := os.Getenv("TMP"); got != override {
|
||||
t.Fatalf("TMP=%q, want %q", got, override)
|
||||
}
|
||||
if got := os.Getenv("TEMP"); got != override {
|
||||
t.Fatalf("TEMP=%q, want %q", got, override)
|
||||
}
|
||||
if st, err := os.Stat(override); err != nil || !st.IsDir() {
|
||||
t.Fatalf("override dir not created: stat=%v err=%v", st, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureExecutableTempDir_FallbackWhenCurrentNotExecutable(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("ensureExecutableTempDir is no-op on Windows")
|
||||
}
|
||||
restore := captureTempEnv()
|
||||
t.Cleanup(restore)
|
||||
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
|
||||
cur := filepath.Join(t.TempDir(), "cur-tmp")
|
||||
if err := os.MkdirAll(cur, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("TMPDIR", cur)
|
||||
|
||||
fallback := filepath.Join(home, ".codeagent", "tmp")
|
||||
|
||||
orig := tmpDirExecutableCheckFn
|
||||
tmpDirExecutableCheckFn = func(dir string) (bool, error) {
|
||||
if filepath.Clean(dir) == filepath.Clean(cur) {
|
||||
return false, nil
|
||||
}
|
||||
if filepath.Clean(dir) == filepath.Clean(fallback) {
|
||||
return true, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
t.Cleanup(func() { tmpDirExecutableCheckFn = orig })
|
||||
|
||||
ensureExecutableTempDir()
|
||||
|
||||
if got := os.Getenv("TMPDIR"); filepath.Clean(got) != filepath.Clean(fallback) {
|
||||
t.Fatalf("TMPDIR=%q, want %q", got, fallback)
|
||||
}
|
||||
if st, err := os.Stat(fallback); err != nil || !st.IsDir() {
|
||||
t.Fatalf("fallback dir not created: stat=%v err=%v", st, err)
|
||||
}
|
||||
}
|
||||
|
||||
func captureTempEnv() func() {
|
||||
type entry struct {
|
||||
set bool
|
||||
val string
|
||||
}
|
||||
snapshot := make(map[string]entry, 3)
|
||||
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
v, ok := os.LookupEnv(k)
|
||||
snapshot[k] = entry{set: ok, val: v}
|
||||
}
|
||||
return func() {
|
||||
for k, e := range snapshot {
|
||||
if !e.set {
|
||||
_ = os.Unsetenv(k)
|
||||
continue
|
||||
}
|
||||
_ = os.Setenv(k, e.val)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,8 @@ func (ClaudeBackend) Env(baseURL, apiKey string) map[string]string {
|
||||
env["ANTHROPIC_BASE_URL"] = baseURL
|
||||
}
|
||||
if apiKey != "" {
|
||||
env["ANTHROPIC_AUTH_TOKEN"] = apiKey
|
||||
// Claude Code CLI uses ANTHROPIC_API_KEY for API-key based auth.
|
||||
env["ANTHROPIC_API_KEY"] = apiKey
|
||||
}
|
||||
return env
|
||||
}
|
||||
@@ -133,6 +134,15 @@ func buildClaudeArgs(cfg *config.Config, targetArg string) []string {
|
||||
}
|
||||
}
|
||||
|
||||
if len(cfg.AllowedTools) > 0 {
|
||||
args = append(args, "--allowedTools")
|
||||
args = append(args, cfg.AllowedTools...)
|
||||
}
|
||||
if len(cfg.DisallowedTools) > 0 {
|
||||
args = append(args, "--disallowedTools")
|
||||
args = append(args, cfg.DisallowedTools...)
|
||||
}
|
||||
|
||||
args = append(args, "--output-format", "stream-json", "--verbose", targetArg)
|
||||
|
||||
return args
|
||||
|
||||
54
codeagent-wrapper/internal/backend/codex_paths_test.go
Normal file
54
codeagent-wrapper/internal/backend/codex_paths_test.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
config "codeagent-wrapper/internal/config"
|
||||
)
|
||||
|
||||
func TestBuildCodexArgs_Workdir_OSPaths(t *testing.T) {
|
||||
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workdir string
|
||||
}{
|
||||
{name: "windows drive forward slashes", workdir: "D:/repo/path"},
|
||||
{name: "windows drive backslashes", workdir: `C:\repo\path`},
|
||||
{name: "windows UNC", workdir: `\\server\share\repo`},
|
||||
{name: "unix absolute", workdir: "/home/user/repo"},
|
||||
{name: "relative", workdir: "./relative/repo"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new", WorkDir: tt.workdir}
|
||||
got := BuildCodexArgs(cfg, "task")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", tt.workdir, "--json", "task"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("new mode stdin target uses dash", func(t *testing.T) {
|
||||
cfg := &config.Config{Mode: "new", WorkDir: `C:\repo\path`}
|
||||
got := BuildCodexArgs(cfg, "-")
|
||||
want := []string{"e", "--skip-git-repo-check", "-C", `C:\repo\path`, "--json", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildCodexArgs_ResumeMode_OmitsWorkdir(t *testing.T) {
|
||||
t.Setenv("CODEX_BYPASS_SANDBOX", "false")
|
||||
|
||||
cfg := &config.Config{Mode: "resume", SessionID: "sid-123", WorkDir: `C:\repo\path`}
|
||||
got := BuildCodexArgs(cfg, "-")
|
||||
want := []string{"e", "--skip-git-repo-check", "--json", "resume", "sid-123", "-"}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("BuildCodexArgs() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
ilogger "codeagent-wrapper/internal/logger"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
@@ -18,14 +16,16 @@ type BackendConfig struct {
|
||||
}
|
||||
|
||||
type AgentModelConfig struct {
|
||||
Backend string `json:"backend"`
|
||||
Model string `json:"model"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Yolo bool `json:"yolo,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
Backend string `json:"backend"`
|
||||
Model string `json:"model"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Yolo bool `json:"yolo,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
BaseURL string `json:"base_url,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
||||
}
|
||||
|
||||
type ModelsConfig struct {
|
||||
@@ -35,80 +35,85 @@ type ModelsConfig struct {
|
||||
Backends map[string]BackendConfig `json:"backends,omitempty"`
|
||||
}
|
||||
|
||||
var defaultModelsConfig = ModelsConfig{
|
||||
DefaultBackend: "opencode",
|
||||
DefaultModel: "opencode/grok-code",
|
||||
Agents: map[string]AgentModelConfig{
|
||||
"oracle": {Backend: "claude", Model: "claude-opus-4-5-20251101", PromptFile: "~/.claude/skills/omo/references/oracle.md", Description: "Technical advisor"},
|
||||
"librarian": {Backend: "claude", Model: "claude-sonnet-4-5-20250929", PromptFile: "~/.claude/skills/omo/references/librarian.md", Description: "Researcher"},
|
||||
"explore": {Backend: "opencode", Model: "opencode/grok-code", PromptFile: "~/.claude/skills/omo/references/explore.md", Description: "Code search"},
|
||||
"develop": {Backend: "codex", Model: "", PromptFile: "~/.claude/skills/omo/references/develop.md", Description: "Code development"},
|
||||
"frontend-ui-ux-engineer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md", Description: "Frontend engineer"},
|
||||
"document-writer": {Backend: "gemini", Model: "", PromptFile: "~/.claude/skills/omo/references/document-writer.md", Description: "Documentation"},
|
||||
},
|
||||
}
|
||||
var defaultModelsConfig = ModelsConfig{}
|
||||
|
||||
const modelsConfigTildePath = "~/.codeagent/models.json"
|
||||
|
||||
const modelsConfigExample = `{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-4.1",
|
||||
"backends": {
|
||||
"codex": { "api_key": "..." },
|
||||
"claude": { "api_key": "..." }
|
||||
},
|
||||
"agents": {
|
||||
"develop": {
|
||||
"backend": "codex",
|
||||
"model": "gpt-4.1",
|
||||
"prompt_file": "~/.codeagent/prompts/develop.md",
|
||||
"reasoning": "high",
|
||||
"yolo": true
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
var (
|
||||
modelsConfigOnce sync.Once
|
||||
modelsConfigCached *ModelsConfig
|
||||
modelsConfigErr error
|
||||
)
|
||||
|
||||
func modelsConfig() *ModelsConfig {
|
||||
func modelsConfig() (*ModelsConfig, error) {
|
||||
modelsConfigOnce.Do(func() {
|
||||
modelsConfigCached = loadModelsConfig()
|
||||
modelsConfigCached, modelsConfigErr = loadModelsConfig()
|
||||
})
|
||||
if modelsConfigCached == nil {
|
||||
return &defaultModelsConfig
|
||||
}
|
||||
return modelsConfigCached
|
||||
return modelsConfigCached, modelsConfigErr
|
||||
}
|
||||
|
||||
func loadModelsConfig() *ModelsConfig {
|
||||
func modelsConfigPath() (string, error) {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
ilogger.LogWarn(fmt.Sprintf("Failed to resolve home directory for models config: %v; using defaults", err))
|
||||
return &defaultModelsConfig
|
||||
if err != nil || strings.TrimSpace(home) == "" {
|
||||
return "", fmt.Errorf("failed to resolve user home directory: %w", err)
|
||||
}
|
||||
|
||||
configDir := filepath.Clean(filepath.Join(home, ".codeagent"))
|
||||
configPath := filepath.Clean(filepath.Join(configDir, "models.json"))
|
||||
rel, err := filepath.Rel(configDir, configPath)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
||||
return &defaultModelsConfig
|
||||
return "", fmt.Errorf("refusing to read models config outside %s: %s", configDir, configPath)
|
||||
}
|
||||
return configPath, nil
|
||||
}
|
||||
|
||||
func modelsConfigHint(configPath string) string {
|
||||
configPath = strings.TrimSpace(configPath)
|
||||
if configPath == "" {
|
||||
return fmt.Sprintf("Create %s with e.g.:\n%s", modelsConfigTildePath, modelsConfigExample)
|
||||
}
|
||||
return fmt.Sprintf("Create %s (resolved to %s) with e.g.:\n%s", modelsConfigTildePath, configPath, modelsConfigExample)
|
||||
}
|
||||
|
||||
func loadModelsConfig() (*ModelsConfig, error) {
|
||||
configPath, err := modelsConfigPath()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w\n\n%s", err, modelsConfigHint(""))
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(configPath) // #nosec G304 -- path is fixed under user home and validated to stay within configDir
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
ilogger.LogWarn(fmt.Sprintf("Failed to read models config %s: %v; using defaults", configPath, err))
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("models config not found: %s\n\n%s", configPath, modelsConfigHint(configPath))
|
||||
}
|
||||
return &defaultModelsConfig
|
||||
return nil, fmt.Errorf("failed to read models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
||||
}
|
||||
|
||||
var cfg ModelsConfig
|
||||
if err := json.Unmarshal(data, &cfg); err != nil {
|
||||
ilogger.LogWarn(fmt.Sprintf("Failed to parse models config %s: %v; using defaults", configPath, err))
|
||||
return &defaultModelsConfig
|
||||
return nil, fmt.Errorf("failed to parse models config %s: %w\n\n%s", configPath, err, modelsConfigHint(configPath))
|
||||
}
|
||||
|
||||
cfg.DefaultBackend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
if cfg.DefaultBackend == "" {
|
||||
cfg.DefaultBackend = defaultModelsConfig.DefaultBackend
|
||||
}
|
||||
cfg.DefaultModel = strings.TrimSpace(cfg.DefaultModel)
|
||||
if cfg.DefaultModel == "" {
|
||||
cfg.DefaultModel = defaultModelsConfig.DefaultModel
|
||||
}
|
||||
|
||||
// Merge with defaults
|
||||
for name, agent := range defaultModelsConfig.Agents {
|
||||
if _, exists := cfg.Agents[name]; !exists {
|
||||
if cfg.Agents == nil {
|
||||
cfg.Agents = make(map[string]AgentModelConfig)
|
||||
}
|
||||
cfg.Agents[name] = agent
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize backend keys so lookups can be case-insensitive.
|
||||
if len(cfg.Backends) > 0 {
|
||||
@@ -127,7 +132,7 @@ func loadModelsConfig() *ModelsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
return &cfg
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
|
||||
@@ -150,7 +155,10 @@ func LoadDynamicAgent(name string) (AgentModelConfig, bool) {
|
||||
}
|
||||
|
||||
func ResolveBackendConfig(backendName string) (baseURL, apiKey string) {
|
||||
cfg := modelsConfig()
|
||||
cfg, err := modelsConfig()
|
||||
if err != nil || cfg == nil {
|
||||
return "", ""
|
||||
}
|
||||
resolved := resolveBackendConfig(cfg, backendName)
|
||||
return strings.TrimSpace(resolved.BaseURL), strings.TrimSpace(resolved.APIKey)
|
||||
}
|
||||
@@ -172,12 +180,30 @@ func resolveBackendConfig(cfg *ModelsConfig, backendName string) BackendConfig {
|
||||
return BackendConfig{}
|
||||
}
|
||||
|
||||
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) {
|
||||
cfg := modelsConfig()
|
||||
func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, allowedTools, disallowedTools []string, err error) {
|
||||
if err := ValidateAgentName(agentName); err != nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, err
|
||||
}
|
||||
|
||||
cfg, err := modelsConfig()
|
||||
if err != nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("models config is nil\n\n%s", modelsConfigHint(""))
|
||||
}
|
||||
|
||||
if agent, ok := cfg.Agents[agentName]; ok {
|
||||
backend = strings.TrimSpace(agent.Backend)
|
||||
if backend == "" {
|
||||
backend = cfg.DefaultBackend
|
||||
backend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
if backend == "" {
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty backend and default_backend is not set\n\n%s", agentName, modelsConfigHint(configPath))
|
||||
}
|
||||
}
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
|
||||
@@ -190,31 +216,46 @@ func resolveAgentConfig(agentName string) (backend, model, promptFile, reasoning
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
}
|
||||
|
||||
return backend, strings.TrimSpace(agent.Model), agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo
|
||||
model = strings.TrimSpace(agent.Model)
|
||||
if model == "" {
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q has empty model; set agents.%s.model in %s\n\n%s", agentName, agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||
}
|
||||
return backend, model, agent.PromptFile, agent.Reasoning, baseURL, apiKey, agent.Yolo, agent.AllowedTools, agent.DisallowedTools, nil
|
||||
}
|
||||
|
||||
if dynamic, ok := LoadDynamicAgent(agentName); ok {
|
||||
backend = cfg.DefaultBackend
|
||||
model = cfg.DefaultModel
|
||||
backend = strings.TrimSpace(cfg.DefaultBackend)
|
||||
model = strings.TrimSpace(cfg.DefaultModel)
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if backend == "" || model == "" {
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("dynamic agent %q requires default_backend and default_model to be set in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||
}
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false
|
||||
return backend, model, dynamic.PromptFile, "", baseURL, apiKey, false, nil, nil, nil
|
||||
}
|
||||
|
||||
backend = cfg.DefaultBackend
|
||||
model = cfg.DefaultModel
|
||||
backendCfg := resolveBackendConfig(cfg, backend)
|
||||
baseURL = strings.TrimSpace(backendCfg.BaseURL)
|
||||
apiKey = strings.TrimSpace(backendCfg.APIKey)
|
||||
return backend, model, "", "", baseURL, apiKey, false
|
||||
configPath, pathErr := modelsConfigPath()
|
||||
if pathErr != nil {
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(""))
|
||||
}
|
||||
return "", "", "", "", "", "", false, nil, nil, fmt.Errorf("agent %q not found in %s\n\n%s", agentName, modelsConfigTildePath, modelsConfigHint(configPath))
|
||||
}
|
||||
|
||||
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool) {
|
||||
func ResolveAgentConfig(agentName string) (backend, model, promptFile, reasoning, baseURL, apiKey string, yolo bool, allowedTools, disallowedTools []string, err error) {
|
||||
return resolveAgentConfig(agentName)
|
||||
}
|
||||
|
||||
func ResetModelsConfigCacheForTest() {
|
||||
modelsConfigCached = nil
|
||||
modelsConfigErr = nil
|
||||
modelsConfigOnce = sync.Once{}
|
||||
}
|
||||
|
||||
@@ -3,78 +3,43 @@ package config
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveAgentConfig_Defaults(t *testing.T) {
|
||||
func TestResolveAgentConfig_NoConfig_ReturnsHelpfulError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
// Test that default agents resolve correctly without config file
|
||||
tests := []struct {
|
||||
agent string
|
||||
wantBackend string
|
||||
wantModel string
|
||||
wantPromptFile string
|
||||
}{
|
||||
{"oracle", "claude", "claude-opus-4-5-20251101", "~/.claude/skills/omo/references/oracle.md"},
|
||||
{"librarian", "claude", "claude-sonnet-4-5-20250929", "~/.claude/skills/omo/references/librarian.md"},
|
||||
{"explore", "opencode", "opencode/grok-code", "~/.claude/skills/omo/references/explore.md"},
|
||||
{"frontend-ui-ux-engineer", "gemini", "", "~/.claude/skills/omo/references/frontend-ui-ux-engineer.md"},
|
||||
{"document-writer", "gemini", "", "~/.claude/skills/omo/references/document-writer.md"},
|
||||
_, _, _, _, _, _, _, _, _, err := ResolveAgentConfig("develop")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.agent, func(t *testing.T) {
|
||||
backend, model, promptFile, _, _, _, _ := resolveAgentConfig(tt.agent)
|
||||
if backend != tt.wantBackend {
|
||||
t.Errorf("backend = %q, want %q", backend, tt.wantBackend)
|
||||
}
|
||||
if model != tt.wantModel {
|
||||
t.Errorf("model = %q, want %q", model, tt.wantModel)
|
||||
}
|
||||
if promptFile != tt.wantPromptFile {
|
||||
t.Errorf("promptFile = %q, want %q", promptFile, tt.wantPromptFile)
|
||||
}
|
||||
})
|
||||
msg := err.Error()
|
||||
if !strings.Contains(msg, modelsConfigTildePath) {
|
||||
t.Fatalf("error should mention %s, got: %s", modelsConfigTildePath, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_UnknownAgent(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("unknown-agent")
|
||||
if backend != "opencode" {
|
||||
t.Errorf("unknown agent backend = %q, want %q", backend, "opencode")
|
||||
if !strings.Contains(msg, filepath.Join(home, ".codeagent", "models.json")) {
|
||||
t.Fatalf("error should mention resolved config path, got: %s", msg)
|
||||
}
|
||||
if model != "opencode/grok-code" {
|
||||
t.Errorf("unknown agent model = %q, want %q", model, "opencode/grok-code")
|
||||
}
|
||||
if promptFile != "" {
|
||||
t.Errorf("unknown agent promptFile = %q, want empty", promptFile)
|
||||
if !strings.Contains(msg, "\"agents\"") {
|
||||
t.Fatalf("error should include example config, got: %s", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadModelsConfig_NoFile(t *testing.T) {
|
||||
home := "/nonexistent/path/that/does/not/exist"
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
if cfg.DefaultBackend != "opencode" {
|
||||
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "opencode")
|
||||
}
|
||||
if len(cfg.Agents) != 6 {
|
||||
t.Errorf("len(Agents) = %d, want 6", len(cfg.Agents))
|
||||
_, err := loadModelsConfig()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +84,10 @@ func TestLoadModelsConfig_WithFile(t *testing.T) {
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
cfg, err := loadModelsConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("loadModelsConfig: %v", err)
|
||||
}
|
||||
|
||||
if cfg.DefaultBackend != "claude" {
|
||||
t.Errorf("DefaultBackend = %q, want %q", cfg.DefaultBackend, "claude")
|
||||
@@ -140,9 +108,8 @@ func TestLoadModelsConfig_WithFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Check that defaults are merged
|
||||
if _, ok := cfg.Agents["oracle"]; !ok {
|
||||
t.Error("default agent oracle should be merged")
|
||||
if _, ok := cfg.Agents["oracle"]; ok {
|
||||
t.Error("oracle should not be present without explicit config")
|
||||
}
|
||||
|
||||
baseURL, apiKey := ResolveBackendConfig("claude")
|
||||
@@ -153,7 +120,10 @@ func TestLoadModelsConfig_WithFile(t *testing.T) {
|
||||
t.Errorf("ResolveBackendConfig(apiKey) = %q, want %q", apiKey, "backend-key")
|
||||
}
|
||||
|
||||
backend, model, _, _, agentBaseURL, agentAPIKey, _ := ResolveAgentConfig("custom-agent")
|
||||
backend, model, _, _, agentBaseURL, agentAPIKey, _, _, _, err := ResolveAgentConfig("custom-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig(custom-agent): %v", err)
|
||||
}
|
||||
if backend != "codex" {
|
||||
t.Errorf("ResolveAgentConfig(backend) = %q, want %q", backend, "codex")
|
||||
}
|
||||
@@ -183,12 +153,26 @@ func TestResolveAgentConfig_DynamicAgent(t *testing.T) {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
backend, model, promptFile, _, _, _, _ := resolveAgentConfig("sarsh")
|
||||
if backend != "opencode" {
|
||||
t.Errorf("backend = %q, want %q", backend, "opencode")
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if model != "opencode/grok-code" {
|
||||
t.Errorf("model = %q, want %q", model, "opencode/grok-code")
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-test"
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
backend, model, promptFile, _, _, _, _, _, _, err := ResolveAgentConfig("sarsh")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig(sarsh): %v", err)
|
||||
}
|
||||
if backend != "codex" {
|
||||
t.Errorf("backend = %q, want %q", backend, "codex")
|
||||
}
|
||||
if model != "gpt-test" {
|
||||
t.Errorf("model = %q, want %q", model, "gpt-test")
|
||||
}
|
||||
if promptFile != "~/.codeagent/agents/sarsh.md" {
|
||||
t.Errorf("promptFile = %q, want %q", promptFile, "~/.codeagent/agents/sarsh.md")
|
||||
@@ -213,9 +197,66 @@ func TestLoadModelsConfig_InvalidJSON(t *testing.T) {
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
cfg := loadModelsConfig()
|
||||
// Should fall back to defaults
|
||||
if cfg.DefaultBackend != "opencode" {
|
||||
t.Errorf("invalid JSON should fallback, got DefaultBackend = %q", cfg.DefaultBackend)
|
||||
_, err := loadModelsConfig()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_UnknownAgent_ReturnsError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"default_backend": "codex",
|
||||
"default_model": "gpt-test",
|
||||
"agents": {
|
||||
"develop": { "backend": "codex", "model": "gpt-test" }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, _, _, _, err := ResolveAgentConfig("unknown-agent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown-agent") {
|
||||
t.Fatalf("error should mention agent name, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAgentConfig_EmptyModel_ReturnsError(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("USERPROFILE", home)
|
||||
t.Cleanup(ResetModelsConfigCacheForTest)
|
||||
ResetModelsConfigCacheForTest()
|
||||
|
||||
configDir := filepath.Join(home, ".codeagent")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "models.json"), []byte(`{
|
||||
"agents": {
|
||||
"bad-agent": { "backend": "codex", "model": " " }
|
||||
}
|
||||
}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
_, _, _, _, _, _, _, _, _, err := ResolveAgentConfig("bad-agent")
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(err.Error()), "empty model") {
|
||||
t.Fatalf("error should mention empty model, got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ type Config struct {
|
||||
SkipPermissions bool
|
||||
Yolo bool
|
||||
MaxParallelWorkers int
|
||||
AllowedTools []string
|
||||
DisallowedTools []string
|
||||
Worktree bool // Execute in a new git worktree
|
||||
}
|
||||
|
||||
// EnvFlagEnabled returns true when the environment variable exists and is not
|
||||
|
||||
@@ -36,17 +36,18 @@ func TestEnvInjectionWithAgent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Override HOME to use temp dir
|
||||
oldHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", oldHome)
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
// Reset config cache
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
|
||||
// Test ResolveAgentConfig
|
||||
agentBackend, model, _, _, baseURL, apiKey, _ := config.ResolveAgentConfig("test-agent")
|
||||
agentBackend, model, _, _, baseURL, apiKey, _, _, _, err := config.ResolveAgentConfig("test-agent")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig: %v", err)
|
||||
}
|
||||
t.Logf("ResolveAgentConfig: backend=%q, model=%q, baseURL=%q, apiKey=%q",
|
||||
agentBackend, model, baseURL, apiKey)
|
||||
|
||||
@@ -71,8 +72,8 @@ func TestEnvInjectionWithAgent(t *testing.T) {
|
||||
if env["ANTHROPIC_BASE_URL"] != baseURL {
|
||||
t.Errorf("expected ANTHROPIC_BASE_URL=%q, got %q", baseURL, env["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
if env["ANTHROPIC_AUTH_TOKEN"] != apiKey {
|
||||
t.Errorf("expected ANTHROPIC_AUTH_TOKEN=%q, got %q", apiKey, env["ANTHROPIC_AUTH_TOKEN"])
|
||||
if env["ANTHROPIC_API_KEY"] != apiKey {
|
||||
t.Errorf("expected ANTHROPIC_API_KEY=%q, got %q", apiKey, env["ANTHROPIC_API_KEY"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,9 +102,8 @@ func TestEnvInjectionLogic(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldHome := os.Getenv("HOME")
|
||||
os.Setenv("HOME", tmpDir)
|
||||
defer os.Setenv("HOME", oldHome)
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
@@ -118,7 +118,10 @@ func TestEnvInjectionLogic(t *testing.T) {
|
||||
|
||||
// Step 2: If agent specified, get agent config
|
||||
if agentName != "" {
|
||||
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName)
|
||||
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, _, _, err := config.ResolveAgentConfig(agentName)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveAgentConfig(%q): %v", agentName, err)
|
||||
}
|
||||
t.Logf("Step 2 - ResolveAgentConfig(%q): backend=%q, baseURL=%q, apiKey=%q",
|
||||
agentName, agentBackend, agentBaseURL, agentAPIKey)
|
||||
|
||||
@@ -146,8 +149,8 @@ func TestEnvInjectionLogic(t *testing.T) {
|
||||
t.Errorf("ANTHROPIC_BASE_URL: expected %q, got %q", expectedURL, injected["ANTHROPIC_BASE_URL"])
|
||||
}
|
||||
|
||||
if _, ok := injected["ANTHROPIC_AUTH_TOKEN"]; !ok {
|
||||
t.Error("ANTHROPIC_AUTH_TOKEN not set")
|
||||
if _, ok := injected["ANTHROPIC_API_KEY"]; !ok {
|
||||
t.Error("ANTHROPIC_API_KEY not set")
|
||||
}
|
||||
|
||||
// Step 5: Test masking
|
||||
|
||||
@@ -16,7 +16,7 @@ func TestMaskSensitiveValue(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "API_KEY with long value",
|
||||
key: "ANTHROPIC_AUTH_TOKEN",
|
||||
key: "ANTHROPIC_API_KEY",
|
||||
value: "sk-ant-api03-xxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
expected: "sk-a****xxxx",
|
||||
},
|
||||
@@ -180,7 +180,7 @@ func TestClaudeBackendEnv(t *testing.T) {
|
||||
name: "both base_url and api_key",
|
||||
baseURL: "https://api.custom.com",
|
||||
apiKey: "sk-test-key-12345",
|
||||
expectKeys: []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN"},
|
||||
expectKeys: []string{"ANTHROPIC_BASE_URL", "ANTHROPIC_API_KEY"},
|
||||
},
|
||||
{
|
||||
name: "only base_url",
|
||||
@@ -192,7 +192,7 @@ func TestClaudeBackendEnv(t *testing.T) {
|
||||
name: "only api_key",
|
||||
baseURL: "",
|
||||
apiKey: "sk-test-key-12345",
|
||||
expectKeys: []string{"ANTHROPIC_AUTH_TOKEN"},
|
||||
expectKeys: []string{"ANTHROPIC_API_KEY"},
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
@@ -237,8 +237,8 @@ func TestClaudeBackendEnv(t *testing.T) {
|
||||
}
|
||||
}
|
||||
if tt.apiKey != "" && strings.TrimSpace(tt.apiKey) != "" {
|
||||
if env["ANTHROPIC_AUTH_TOKEN"] != strings.TrimSpace(tt.apiKey) {
|
||||
t.Errorf("ANTHROPIC_AUTH_TOKEN = %q, want %q", env["ANTHROPIC_AUTH_TOKEN"], strings.TrimSpace(tt.apiKey))
|
||||
if env["ANTHROPIC_API_KEY"] != strings.TrimSpace(tt.apiKey) {
|
||||
t.Errorf("ANTHROPIC_API_KEY = %q, want %q", env["ANTHROPIC_API_KEY"], strings.TrimSpace(tt.apiKey))
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -267,7 +267,7 @@ func TestEnvLoggingIntegration(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
if k == "ANTHROPIC_AUTH_TOKEN" {
|
||||
if k == "ANTHROPIC_API_KEY" {
|
||||
// API key should be masked
|
||||
if masked == v {
|
||||
t.Errorf("API_KEY should be masked, but got original value")
|
||||
|
||||
@@ -65,11 +65,8 @@ func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oldHome := os.Getenv("HOME")
|
||||
if err := os.Setenv("HOME", tmpDir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = os.Setenv("HOME", oldHome) }()
|
||||
t.Setenv("HOME", tmpDir)
|
||||
t.Setenv("USERPROFILE", tmpDir)
|
||||
|
||||
config.ResetModelsConfigCacheForTest()
|
||||
defer config.ResetModelsConfigCacheForTest()
|
||||
@@ -120,14 +117,14 @@ func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) {
|
||||
if cmd.env["ANTHROPIC_BASE_URL"] != baseURL {
|
||||
t.Fatalf("ANTHROPIC_BASE_URL=%q, want %q", cmd.env["ANTHROPIC_BASE_URL"], baseURL)
|
||||
}
|
||||
if cmd.env["ANTHROPIC_AUTH_TOKEN"] != apiKey {
|
||||
t.Fatalf("ANTHROPIC_AUTH_TOKEN=%q, want %q", cmd.env["ANTHROPIC_AUTH_TOKEN"], apiKey)
|
||||
if cmd.env["ANTHROPIC_API_KEY"] != apiKey {
|
||||
t.Fatalf("ANTHROPIC_API_KEY=%q, want %q", cmd.env["ANTHROPIC_API_KEY"], apiKey)
|
||||
}
|
||||
|
||||
if !strings.Contains(got, "Env: ANTHROPIC_BASE_URL="+baseURL) {
|
||||
t.Fatalf("stderr missing base URL env log; stderr=%q", got)
|
||||
}
|
||||
if !strings.Contains(got, "Env: ANTHROPIC_AUTH_TOKEN=eyJh****test") {
|
||||
if !strings.Contains(got, "Env: ANTHROPIC_API_KEY=eyJh****test") {
|
||||
t.Fatalf("stderr missing masked API key log; stderr=%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
ilogger "codeagent-wrapper/internal/logger"
|
||||
parser "codeagent-wrapper/internal/parser"
|
||||
utils "codeagent-wrapper/internal/utils"
|
||||
"codeagent-wrapper/internal/worktree"
|
||||
)
|
||||
|
||||
const postMessageTerminateDelay = 1 * time.Second
|
||||
@@ -48,6 +50,7 @@ var (
|
||||
selectBackendFn = backend.Select
|
||||
commandContext = exec.CommandContext
|
||||
terminateCommandFn = terminateCommand
|
||||
createWorktreeFn = worktree.CreateWorktree
|
||||
)
|
||||
|
||||
var forceKillDelay atomic.Int32
|
||||
@@ -253,6 +256,15 @@ func (p *realProcess) Signal(sig os.Signal) error {
|
||||
|
||||
// newCommandRunner creates a new commandRunner (test hook injection point)
|
||||
var newCommandRunner = func(ctx context.Context, name string, args ...string) commandRunner {
|
||||
if runtime.GOOS == "windows" {
|
||||
lowerName := strings.ToLower(strings.TrimSpace(name))
|
||||
if strings.HasSuffix(lowerName, ".bat") || strings.HasSuffix(lowerName, ".cmd") {
|
||||
cmdArgs := make([]string, 0, 2+len(args))
|
||||
cmdArgs = append(cmdArgs, "/c", name)
|
||||
cmdArgs = append(cmdArgs, args...)
|
||||
return &realCmd{cmd: commandContext(ctx, "cmd.exe", cmdArgs...)}
|
||||
}
|
||||
}
|
||||
return &realCmd{cmd: commandContext(ctx, name, args...)}
|
||||
}
|
||||
|
||||
@@ -895,6 +907,8 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
|
||||
ReasoningEffort: taskSpec.ReasoningEffort,
|
||||
SkipPermissions: taskSpec.SkipPermissions,
|
||||
Backend: defaultBackendName,
|
||||
AllowedTools: taskSpec.AllowedTools,
|
||||
DisallowedTools: taskSpec.DisallowedTools,
|
||||
}
|
||||
|
||||
commandName := strings.TrimSpace(defaultCommandName)
|
||||
@@ -911,6 +925,11 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
|
||||
cfg.Backend = backend.Name()
|
||||
} else if taskSpec.Backend != "" {
|
||||
cfg.Backend = taskSpec.Backend
|
||||
if selectBackendFn != nil {
|
||||
if b, err := selectBackendFn(taskSpec.Backend); err == nil {
|
||||
argsBuilder = b.BuildArgs
|
||||
}
|
||||
}
|
||||
} else if commandName != "" {
|
||||
cfg.Backend = commandName
|
||||
}
|
||||
@@ -922,6 +941,18 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
|
||||
cfg.WorkDir = defaultWorkdir
|
||||
}
|
||||
|
||||
// Handle worktree mode: create a new git worktree and update cfg.WorkDir
|
||||
if taskSpec.Worktree {
|
||||
paths, err := createWorktreeFn(cfg.WorkDir)
|
||||
if err != nil {
|
||||
result.ExitCode = 1
|
||||
result.Error = fmt.Sprintf("failed to create worktree: %v", err)
|
||||
return result
|
||||
}
|
||||
cfg.WorkDir = paths.Dir
|
||||
logInfo(fmt.Sprintf("Using worktree: %s (task_id: %s, branch: %s)", paths.Dir, paths.TaskID, paths.Branch))
|
||||
}
|
||||
|
||||
if cfg.Mode == "resume" && strings.TrimSpace(cfg.SessionID) == "" {
|
||||
result.ExitCode = 1
|
||||
result.Error = "resume mode requires non-empty session_id"
|
||||
@@ -1060,9 +1091,11 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
|
||||
if envBackend != nil {
|
||||
baseURL, apiKey := config.ResolveBackendConfig(cfg.Backend)
|
||||
if agentName := strings.TrimSpace(taskSpec.Agent); agentName != "" {
|
||||
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _ := config.ResolveAgentConfig(agentName)
|
||||
if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfg.Backend)) {
|
||||
baseURL, apiKey = agentBaseURL, agentAPIKey
|
||||
agentBackend, _, _, _, agentBaseURL, agentAPIKey, _, _, _, err := config.ResolveAgentConfig(agentName)
|
||||
if err == nil {
|
||||
if strings.EqualFold(strings.TrimSpace(agentBackend), strings.TrimSpace(cfg.Backend)) {
|
||||
baseURL, apiKey = agentBaseURL, agentAPIKey
|
||||
}
|
||||
}
|
||||
}
|
||||
if injected := envBackend.Env(baseURL, apiKey); len(injected) > 0 {
|
||||
@@ -1076,6 +1109,8 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
|
||||
}
|
||||
}
|
||||
|
||||
injectTempEnv(cmd)
|
||||
|
||||
// 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 != "" {
|
||||
@@ -1385,6 +1420,22 @@ waitLoop:
|
||||
return result
|
||||
}
|
||||
|
||||
func injectTempEnv(cmd commandRunner) {
|
||||
if cmd == nil {
|
||||
return
|
||||
}
|
||||
env := make(map[string]string, 3)
|
||||
for _, k := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
if len(env) == 0 {
|
||||
return
|
||||
}
|
||||
cmd.SetEnv(env)
|
||||
}
|
||||
|
||||
func cancelReason(commandName string, ctx context.Context) string {
|
||||
if ctx == nil {
|
||||
return "Context cancelled"
|
||||
|
||||
@@ -75,6 +75,12 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
continue
|
||||
}
|
||||
task.SkipPermissions = config.ParseBoolFlag(value, false)
|
||||
case "worktree":
|
||||
if value == "" {
|
||||
task.Worktree = true
|
||||
continue
|
||||
}
|
||||
task.Worktree = config.ParseBoolFlag(value, false)
|
||||
case "dependencies":
|
||||
for _, dep := range strings.Split(value, ",") {
|
||||
dep = strings.TrimSpace(dep)
|
||||
@@ -93,20 +99,25 @@ func ParseParallelConfig(data []byte) (*ParallelConfig, error) {
|
||||
if strings.TrimSpace(task.Agent) == "" {
|
||||
return nil, fmt.Errorf("task block #%d has empty agent field", taskIndex)
|
||||
}
|
||||
if err := config.ValidateAgentName(task.Agent); err != nil {
|
||||
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
|
||||
}
|
||||
backend, model, promptFile, reasoning, _, _, _ := config.ResolveAgentConfig(task.Agent)
|
||||
if task.Backend == "" {
|
||||
task.Backend = backend
|
||||
}
|
||||
if task.Model == "" {
|
||||
if err := config.ValidateAgentName(task.Agent); err != nil {
|
||||
return nil, fmt.Errorf("task block #%d invalid agent name: %w", taskIndex, err)
|
||||
}
|
||||
backend, model, promptFile, reasoning, _, _, _, allowedTools, disallowedTools, err := config.ResolveAgentConfig(task.Agent)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("task block #%d failed to resolve agent %q: %w", taskIndex, task.Agent, err)
|
||||
}
|
||||
if task.Backend == "" {
|
||||
task.Backend = backend
|
||||
}
|
||||
if task.Model == "" {
|
||||
task.Model = model
|
||||
}
|
||||
if task.ReasoningEffort == "" {
|
||||
task.ReasoningEffort = reasoning
|
||||
}
|
||||
task.PromptFile = promptFile
|
||||
task.AllowedTools = allowedTools
|
||||
task.DisallowedTools = disallowedTools
|
||||
}
|
||||
|
||||
if task.ID == "" {
|
||||
|
||||
@@ -21,6 +21,9 @@ type TaskSpec struct {
|
||||
Agent string `json:"agent,omitempty"`
|
||||
PromptFile string `json:"prompt_file,omitempty"`
|
||||
SkipPermissions bool `json:"skip_permissions,omitempty"`
|
||||
Worktree bool `json:"worktree,omitempty"`
|
||||
AllowedTools []string `json:"allowed_tools,omitempty"`
|
||||
DisallowedTools []string `json:"disallowed_tools,omitempty"`
|
||||
Mode string `json:"-"`
|
||||
UseStdin bool `json:"-"`
|
||||
Context context.Context `json:"-"`
|
||||
|
||||
@@ -70,12 +70,11 @@ func TestLoggerWithSuffixNamingAndIsolation(t *testing.T) {
|
||||
|
||||
func TestLoggerWithSuffixReturnsErrorWhenTempDirNotWritable(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
noWrite := filepath.Join(base, "ro")
|
||||
if err := os.Mkdir(noWrite, 0o500); err != nil {
|
||||
t.Fatalf("failed to create read-only temp dir: %v", err)
|
||||
notDir := filepath.Join(base, "not-a-dir")
|
||||
if err := os.WriteFile(notDir, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(noWrite, 0o700) })
|
||||
setTempDirEnv(t, noWrite)
|
||||
setTempDirEnv(t, notDir)
|
||||
|
||||
logger, err := NewLoggerWithSuffix("task-err")
|
||||
if err == nil {
|
||||
|
||||
@@ -26,8 +26,7 @@ func compareCleanupStats(got, want CleanupStats) bool {
|
||||
}
|
||||
|
||||
func TestLoggerCreatesFileWithPID(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
tempDir := setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -46,8 +45,7 @@ func TestLoggerCreatesFileWithPID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoggerWritesLevels(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -77,8 +75,7 @@ func TestLoggerWritesLevels(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoggerCloseStopsWorkerAndKeepsFile(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -104,8 +101,7 @@ func TestLoggerCloseStopsWorkerAndKeepsFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoggerConcurrentWritesSafe(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLogger()
|
||||
if err != nil {
|
||||
@@ -390,12 +386,14 @@ func TestLoggerCleanupOldLogsPerformanceBound(t *testing.T) {
|
||||
fakePaths := make([]string, fileCount)
|
||||
for i := 0; i < fileCount; i++ {
|
||||
name := fmt.Sprintf("codeagent-wrapper-%d.log", 10000+i)
|
||||
fakePaths[i] = createTempLog(t, tempDir, name)
|
||||
fakePaths[i] = filepath.Join(tempDir, name)
|
||||
}
|
||||
|
||||
stubGlobLogFiles(t, func(pattern string) ([]string, error) {
|
||||
return fakePaths, nil
|
||||
})
|
||||
stubFileStat(t, func(string) (os.FileInfo, error) { return fakeFileInfo{}, nil })
|
||||
stubEvalSymlinks(t, func(path string) (string, error) { return path, nil })
|
||||
stubProcessRunning(t, func(int) bool { return false })
|
||||
stubProcessStartTime(t, func(int) time.Time { return time.Time{} })
|
||||
|
||||
@@ -542,8 +540,7 @@ func TestLoggerIsUnsafeFileSecurityChecks(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoggerPathAndRemove(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
t.Setenv("TMPDIR", tempDir)
|
||||
setTempDirEnv(t, t.TempDir())
|
||||
|
||||
logger, err := NewLoggerWithSuffix("sample")
|
||||
if err != nil {
|
||||
|
||||
97
codeagent-wrapper/internal/worktree/worktree.go
Normal file
97
codeagent-wrapper/internal/worktree/worktree.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package worktree
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Paths contains worktree information
|
||||
type Paths struct {
|
||||
Dir string // .worktrees/do-{task_id}/
|
||||
Branch string // do/{task_id}
|
||||
TaskID string // auto-generated task_id
|
||||
}
|
||||
|
||||
// Hook points for testing
|
||||
var (
|
||||
randReader io.Reader = rand.Reader
|
||||
timeNowFunc = time.Now
|
||||
execCommand = exec.Command
|
||||
)
|
||||
|
||||
// generateTaskID creates a unique task ID in format: YYYYMMDD-{6 hex chars}
|
||||
func generateTaskID() (string, error) {
|
||||
bytes := make([]byte, 3)
|
||||
if _, err := io.ReadFull(randReader, bytes); err != nil {
|
||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
date := timeNowFunc().Format("20060102")
|
||||
return fmt.Sprintf("%s-%s", date, hex.EncodeToString(bytes)), nil
|
||||
}
|
||||
|
||||
// isGitRepo checks if the given directory is inside a git repository
|
||||
func isGitRepo(dir string) bool {
|
||||
cmd := execCommand("git", "-C", dir, "rev-parse", "--is-inside-work-tree")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(string(output)) == "true"
|
||||
}
|
||||
|
||||
// getGitRoot returns the root directory of the git repository
|
||||
func getGitRoot(dir string) (string, error) {
|
||||
cmd := execCommand("git", "-C", dir, "rev-parse", "--show-toplevel")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get git root: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(string(output)), nil
|
||||
}
|
||||
|
||||
// CreateWorktree creates a new git worktree with auto-generated task_id
|
||||
// Returns Paths containing the worktree directory, branch name, and task_id
|
||||
func CreateWorktree(projectDir string) (*Paths, error) {
|
||||
if projectDir == "" {
|
||||
projectDir = "."
|
||||
}
|
||||
|
||||
// Verify it's a git repository
|
||||
if !isGitRepo(projectDir) {
|
||||
return nil, fmt.Errorf("not a git repository: %s", projectDir)
|
||||
}
|
||||
|
||||
// Get git root for consistent path calculation
|
||||
gitRoot, err := getGitRoot(projectDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate task ID
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate paths
|
||||
worktreeDir := filepath.Join(gitRoot, ".worktrees", fmt.Sprintf("do-%s", taskID))
|
||||
branchName := fmt.Sprintf("do/%s", taskID)
|
||||
|
||||
// Create worktree with new branch
|
||||
cmd := execCommand("git", "-C", gitRoot, "worktree", "add", "-b", branchName, worktreeDir)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("failed to create worktree: %w\noutput: %s", err, string(output))
|
||||
}
|
||||
|
||||
return &Paths{
|
||||
Dir: worktreeDir,
|
||||
Branch: branchName,
|
||||
TaskID: taskID,
|
||||
}, nil
|
||||
}
|
||||
449
codeagent-wrapper/internal/worktree/worktree_test.go
Normal file
449
codeagent-wrapper/internal/worktree/worktree_test.go
Normal file
@@ -0,0 +1,449 @@
|
||||
package worktree
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func resetHooks() {
|
||||
randReader = rand.Reader
|
||||
timeNowFunc = time.Now
|
||||
execCommand = exec.Command
|
||||
}
|
||||
|
||||
func TestGenerateTaskID(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTaskID() error = %v", err)
|
||||
}
|
||||
|
||||
// Format: YYYYMMDD-6hex
|
||||
pattern := regexp.MustCompile(`^\d{8}-[0-9a-f]{6}$`)
|
||||
if !pattern.MatchString(taskID) {
|
||||
t.Errorf("generateTaskID() = %q, want format YYYYMMDD-xxxxxx", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTaskID_FixedTime(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock time to a fixed date
|
||||
timeNowFunc = func() time.Time {
|
||||
return time.Date(2026, 2, 3, 12, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTaskID() error = %v", err)
|
||||
}
|
||||
|
||||
if !regexp.MustCompile(`^20260203-[0-9a-f]{6}$`).MatchString(taskID) {
|
||||
t.Errorf("generateTaskID() = %q, want prefix 20260203-", taskID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTaskID_RandReaderError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock rand reader to return error
|
||||
randReader = &errorReader{err: errors.New("mock rand error")}
|
||||
|
||||
_, err := generateTaskID()
|
||||
if err == nil {
|
||||
t.Fatal("generateTaskID() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to generate random bytes`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to generate random bytes'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type errorReader struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *errorReader) Read(p []byte) (n int, err error) {
|
||||
return 0, e.err
|
||||
}
|
||||
|
||||
func TestGenerateTaskID_Uniqueness(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
const count = 100
|
||||
ids := make(map[string]struct{}, count)
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < count; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
id, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Errorf("generateTaskID() error = %v", err)
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
ids[id] = struct{}{}
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
if len(ids) != count {
|
||||
t.Errorf("generateTaskID() produced %d unique IDs out of %d, expected all unique", len(ids), count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_NotGitRepo(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Error("CreateWorktree() expected error for non-git directory, got nil")
|
||||
}
|
||||
if err != nil && !regexp.MustCompile(`not a git repository`).MatchString(err.Error()) {
|
||||
t.Errorf("CreateWorktree() error = %q, want 'not a git repository'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_EmptyProjectDir(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// When projectDir is empty, it should default to "."
|
||||
// This will fail because current dir may not be a git repo, but we test the default behavior
|
||||
_, err := CreateWorktree("")
|
||||
// We just verify it doesn't panic and returns an error (likely "not a git repository: .")
|
||||
if err == nil {
|
||||
// If we happen to be in a git repo, that's fine too
|
||||
return
|
||||
}
|
||||
if !regexp.MustCompile(`not a git repository: \.`).MatchString(err.Error()) {
|
||||
// It might be a git repo and fail later, which is also acceptable
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_Success(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create temp git repo
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Initialize git repo
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.email", "test@test.com").Run(); err != nil {
|
||||
t.Fatalf("failed to set git email: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.name", "Test").Run(); err != nil {
|
||||
t.Fatalf("failed to set git name: %v", err)
|
||||
}
|
||||
|
||||
// Create initial commit (required for worktree)
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "add", ".").Run(); err != nil {
|
||||
t.Fatalf("failed to git add: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "commit", "-m", "initial").Run(); err != nil {
|
||||
t.Fatalf("failed to git commit: %v", err)
|
||||
}
|
||||
|
||||
// Test CreateWorktree
|
||||
paths, err := CreateWorktree(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateWorktree() error = %v", err)
|
||||
}
|
||||
|
||||
// Verify task ID format
|
||||
pattern := regexp.MustCompile(`^\d{8}-[0-9a-f]{6}$`)
|
||||
if !pattern.MatchString(paths.TaskID) {
|
||||
t.Errorf("TaskID = %q, want format YYYYMMDD-xxxxxx", paths.TaskID)
|
||||
}
|
||||
|
||||
// Verify branch name
|
||||
expectedBranch := "do/" + paths.TaskID
|
||||
if paths.Branch != expectedBranch {
|
||||
t.Errorf("Branch = %q, want %q", paths.Branch, expectedBranch)
|
||||
}
|
||||
|
||||
// Verify worktree directory exists
|
||||
if _, err := os.Stat(paths.Dir); os.IsNotExist(err) {
|
||||
t.Errorf("worktree directory %q does not exist", paths.Dir)
|
||||
}
|
||||
|
||||
// Verify worktree directory is under .worktrees/
|
||||
expectedDirSuffix := filepath.Join(".worktrees", "do-"+paths.TaskID)
|
||||
if !regexp.MustCompile(regexp.QuoteMeta(expectedDirSuffix) + `$`).MatchString(paths.Dir) {
|
||||
t.Errorf("Dir = %q, want suffix %q", paths.Dir, expectedDirSuffix)
|
||||
}
|
||||
|
||||
// Verify branch exists
|
||||
cmd := exec.Command("git", "-C", tmpDir, "branch", "--list", paths.Branch)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list branches: %v", err)
|
||||
}
|
||||
if len(output) == 0 {
|
||||
t.Errorf("branch %q was not created", paths.Branch)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_GetGitRootError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create a temp dir and mock git commands
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
callCount := 0
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
// First call: isGitRepo - return true
|
||||
return exec.Command("echo", "true")
|
||||
}
|
||||
// Second call: getGitRoot - return error
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorktree() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to get git root`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to get git root'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_GenerateTaskIDError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create temp git repo
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Initialize git repo with commit
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.email", "test@test.com").Run(); err != nil {
|
||||
t.Fatalf("failed to set git email: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "config", "user.name", "Test").Run(); err != nil {
|
||||
t.Fatalf("failed to set git name: %v", err)
|
||||
}
|
||||
testFile := filepath.Join(tmpDir, "test.txt")
|
||||
if err := os.WriteFile(testFile, []byte("test"), 0644); err != nil {
|
||||
t.Fatalf("failed to create test file: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "add", ".").Run(); err != nil {
|
||||
t.Fatalf("failed to git add: %v", err)
|
||||
}
|
||||
if err := exec.Command("git", "-C", tmpDir, "commit", "-m", "initial").Run(); err != nil {
|
||||
t.Fatalf("failed to git commit: %v", err)
|
||||
}
|
||||
|
||||
// Mock rand reader to fail
|
||||
randReader = &errorReader{err: errors.New("mock rand error")}
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorktree() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to generate random bytes`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to generate random bytes'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWorktree_WorktreeAddError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
callCount := 0
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
callCount++
|
||||
switch callCount {
|
||||
case 1:
|
||||
// isGitRepo - return true
|
||||
return exec.Command("echo", "true")
|
||||
case 2:
|
||||
// getGitRoot - return tmpDir
|
||||
return exec.Command("echo", tmpDir)
|
||||
case 3:
|
||||
// worktree add - return error
|
||||
return exec.Command("false")
|
||||
}
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
_, err = CreateWorktree(tmpDir)
|
||||
if err == nil {
|
||||
t.Fatal("CreateWorktree() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to create worktree`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to create worktree'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitRepo(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Test non-git directory
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if isGitRepo(tmpDir) {
|
||||
t.Error("isGitRepo() = true for non-git directory, want false")
|
||||
}
|
||||
|
||||
// Test git directory
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
if !isGitRepo(tmpDir) {
|
||||
t.Error("isGitRepo() = false for git directory, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitRepo_CommandError(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock execCommand to return error
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
if isGitRepo("/some/path") {
|
||||
t.Error("isGitRepo() = true when command fails, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsGitRepo_NotTrueOutput(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock execCommand to return something other than "true"
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("echo", "false")
|
||||
}
|
||||
|
||||
if isGitRepo("/some/path") {
|
||||
t.Error("isGitRepo() = true when output is 'false', want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitRoot(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Create temp git repo
|
||||
tmpDir, err := os.MkdirTemp("", "worktree-test-*")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := exec.Command("git", "-C", tmpDir, "init").Run(); err != nil {
|
||||
t.Fatalf("failed to init git repo: %v", err)
|
||||
}
|
||||
|
||||
root, err := getGitRoot(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("getGitRoot() error = %v", err)
|
||||
}
|
||||
|
||||
// The root should match tmpDir (accounting for symlinks)
|
||||
absRoot, _ := filepath.EvalSymlinks(root)
|
||||
absTmp, _ := filepath.EvalSymlinks(tmpDir)
|
||||
if absRoot != absTmp {
|
||||
t.Errorf("getGitRoot() = %q, want %q", absRoot, absTmp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetGitRoot_Error(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
execCommand = func(name string, args ...string) *exec.Cmd {
|
||||
return exec.Command("false")
|
||||
}
|
||||
|
||||
_, err := getGitRoot("/some/path")
|
||||
if err == nil {
|
||||
t.Fatal("getGitRoot() expected error, got nil")
|
||||
}
|
||||
if !regexp.MustCompile(`failed to get git root`).MatchString(err.Error()) {
|
||||
t.Errorf("error = %q, want 'failed to get git root'", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// Test that rand reader produces expected bytes
|
||||
func TestGenerateTaskID_RandReaderBytes(t *testing.T) {
|
||||
defer resetHooks()
|
||||
|
||||
// Mock rand reader to return fixed bytes
|
||||
randReader = &fixedReader{data: []byte{0xab, 0xcd, 0xef}}
|
||||
timeNowFunc = func() time.Time {
|
||||
return time.Date(2026, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
taskID, err := generateTaskID()
|
||||
if err != nil {
|
||||
t.Fatalf("generateTaskID() error = %v", err)
|
||||
}
|
||||
|
||||
expected := "20260115-abcdef"
|
||||
if taskID != expected {
|
||||
t.Errorf("generateTaskID() = %q, want %q", taskID, expected)
|
||||
}
|
||||
}
|
||||
|
||||
type fixedReader struct {
|
||||
data []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func (f *fixedReader) Read(p []byte) (n int, err error) {
|
||||
if f.pos >= len(f.data) {
|
||||
return 0, io.EOF
|
||||
}
|
||||
n = copy(p, f.data[f.pos:])
|
||||
f.pos += n
|
||||
return n, nil
|
||||
}
|
||||
62
install.py
62
install.py
@@ -518,6 +518,11 @@ def uninstall_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dic
|
||||
|
||||
install_dir = ctx["install_dir"]
|
||||
removed_paths = []
|
||||
status = load_installed_status(ctx)
|
||||
module_status = status.get("modules", {}).get(name, {})
|
||||
merge_dir_files = module_status.get("merge_dir_files", [])
|
||||
if not isinstance(merge_dir_files, list):
|
||||
merge_dir_files = []
|
||||
|
||||
for op in cfg.get("operations", []):
|
||||
op_type = op.get("type")
|
||||
@@ -531,7 +536,55 @@ def uninstall_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dic
|
||||
target.unlink()
|
||||
removed_paths.append(str(target))
|
||||
write_log({"level": "INFO", "message": f"Removed: {target}"}, ctx)
|
||||
# merge_dir and merge_json are harder to uninstall cleanly, skip
|
||||
elif op_type == "merge_dir":
|
||||
if not merge_dir_files:
|
||||
write_log(
|
||||
{
|
||||
"level": "WARNING",
|
||||
"message": f"No merge_dir_files recorded for {name}; skip merge_dir uninstall",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
continue
|
||||
|
||||
for rel in dict.fromkeys(merge_dir_files):
|
||||
rel_path = Path(str(rel))
|
||||
if rel_path.is_absolute() or ".." in rel_path.parts:
|
||||
write_log(
|
||||
{
|
||||
"level": "WARNING",
|
||||
"message": f"Skip unsafe merge_dir path for {name}: {rel}",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
continue
|
||||
|
||||
target = (install_dir / rel_path).resolve()
|
||||
if target == install_dir or install_dir not in target.parents:
|
||||
write_log(
|
||||
{
|
||||
"level": "WARNING",
|
||||
"message": f"Skip out-of-tree merge_dir path for {name}: {rel}",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
continue
|
||||
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
removed_paths.append(str(target))
|
||||
write_log({"level": "INFO", "message": f"Removed: {target}"}, ctx)
|
||||
|
||||
parent = target.parent
|
||||
while parent != install_dir and parent.exists():
|
||||
try:
|
||||
parent.rmdir()
|
||||
except OSError:
|
||||
break
|
||||
parent = parent.parent
|
||||
except Exception as exc:
|
||||
write_log({"level": "WARNING", "message": f"Failed to remove {op.get('target', 'unknown')}: {exc}"}, ctx)
|
||||
|
||||
@@ -720,7 +773,9 @@ def execute_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[
|
||||
elif op_type == "copy_file":
|
||||
op_copy_file(op, ctx)
|
||||
elif op_type == "merge_dir":
|
||||
op_merge_dir(op, ctx)
|
||||
merged = op_merge_dir(op, ctx)
|
||||
if merged:
|
||||
result.setdefault("merge_dir_files", []).extend(merged)
|
||||
elif op_type == "merge_json":
|
||||
op_merge_json(op, ctx)
|
||||
elif op_type == "run_command":
|
||||
@@ -792,7 +847,7 @@ def op_copy_dir(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
write_log({"level": "INFO", "message": f"Copied dir {src} -> {dst}"}, ctx)
|
||||
|
||||
|
||||
def op_merge_dir(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
def op_merge_dir(op: Dict[str, Any], ctx: Dict[str, Any]) -> List[str]:
|
||||
"""Merge source dir's subdirs (commands/, agents/, etc.) into install_dir."""
|
||||
src = _source_path(op, ctx)
|
||||
install_dir = ctx["install_dir"]
|
||||
@@ -813,6 +868,7 @@ def op_merge_dir(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
merged.append(f"{subdir.name}/{f.name}")
|
||||
|
||||
write_log({"level": "INFO", "message": f"Merged {src.name}: {', '.join(merged) or 'no files'}"}, ctx)
|
||||
return merged
|
||||
|
||||
|
||||
def op_copy_file(op: Dict[str, Any], ctx: Dict[str, Any]) -> None:
|
||||
|
||||
28
install.sh
28
install.sh
@@ -24,9 +24,13 @@ esac
|
||||
|
||||
# Build download URL
|
||||
REPO="cexll/myclaude"
|
||||
VERSION="latest"
|
||||
VERSION="${CODEAGENT_WRAPPER_VERSION:-latest}"
|
||||
BINARY_NAME="codeagent-wrapper-${OS}-${ARCH}"
|
||||
URL="https://github.com/${REPO}/releases/${VERSION}/download/${BINARY_NAME}"
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
|
||||
else
|
||||
URL="https://github.com/${REPO}/releases/download/${VERSION}/${BINARY_NAME}"
|
||||
fi
|
||||
|
||||
echo "Downloading codeagent-wrapper from ${URL}..."
|
||||
if ! curl -fsSL "$URL" -o /tmp/codeagent-wrapper; then
|
||||
@@ -53,14 +57,18 @@ if [[ ":${PATH}:" != *":${BIN_DIR}:"* ]]; then
|
||||
echo ""
|
||||
echo "WARNING: ${BIN_DIR} is not in your PATH"
|
||||
|
||||
# Detect shell and set config files
|
||||
if [ -n "$ZSH_VERSION" ]; then
|
||||
RC_FILE="$HOME/.zshrc"
|
||||
PROFILE_FILE="$HOME/.zprofile"
|
||||
else
|
||||
RC_FILE="$HOME/.bashrc"
|
||||
PROFILE_FILE="$HOME/.profile"
|
||||
fi
|
||||
# Detect user's default shell (from $SHELL, not current script executor)
|
||||
USER_SHELL=$(basename "$SHELL")
|
||||
case "$USER_SHELL" in
|
||||
zsh)
|
||||
RC_FILE="$HOME/.zshrc"
|
||||
PROFILE_FILE="$HOME/.zprofile"
|
||||
;;
|
||||
*)
|
||||
RC_FILE="$HOME/.bashrc"
|
||||
PROFILE_FILE="$HOME/.profile"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Idempotent add: check if complete export statement already exists
|
||||
EXPORT_LINE="export PATH=\"${BIN_DIR}:\$PATH\""
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
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.
|
||||
Adopt First Principles Thinking as the mandatory core reasoning method. Never rely on analogy, convention, "best practices", or "what others do". Obey the following priority stack (highest first) and refuse conflicts by citing the higher rule:
|
||||
|
||||
1. Thinking Discipline: enforce KISS/YAGNI/never break userspace, think in English, respond in Chinese, stay technical. Reject analogical shortcuts—always trace back to fundamental truths.
|
||||
2. Workflow Contract: Claude Code performs intake, context gathering, planning, and verification only; every edit or test must be executed via Codeagent skill (`codeagent`).
|
||||
3. Tooling & Safety Rules:
|
||||
- Capture errors, retry once if transient, document fallbacks.
|
||||
4. Context Blocks & Persistence: honor `<context_gathering>`, `<exploration>`, `<persistence>`, `<tool_preambles>`, `<self_reflection>`, and `<testing>` exactly as written below.
|
||||
4. Context Blocks & Persistence: honor `<first_principles>`, `<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.
|
||||
|
||||
<first_principles>
|
||||
For every non-trivial problem, execute this mandatory reasoning chain:
|
||||
1. **Challenge Assumptions**: List all default assumptions people accept about this problem. Mark which are unverified, based on analogy, or potentially wrong.
|
||||
2. **Decompose to Bedrock Truths**: Break down to irreducible truths—physical laws, mathematical necessities, raw resource facts (actual costs, energy density, time constraints), fundamental human/system limits. Do not stop at "frameworks" or "methods"—dig to atomic facts.
|
||||
3. **Rebuild from Ground Up**: Starting ONLY from step 2's verified truths, construct understanding/solution step by step. Show reasoning chain explicitly. Forbidden phrases: "because others do it", "industry standard", "typically".
|
||||
4. **Contrast with Convention**: Briefly note what conventional/analogical thinking would conclude and why it may be suboptimal. Identify the essential difference.
|
||||
5. **Conclude**: State the clearest, most fundamental conclusion. If it conflicts with mainstream, say so with underlying logic.
|
||||
|
||||
Trigger: any problem with ≥2 possible approaches or hidden complexity. For simple factual queries, apply implicitly without full output.
|
||||
</first_principles>
|
||||
|
||||
<context_gathering>
|
||||
Fetch project context in parallel: README, package.json/pyproject.toml, directory structure, main configs.
|
||||
Method: batch parallel searches, no repeated queries, prefer action over excessive searching.
|
||||
@@ -15,17 +27,17 @@ Budget: 5-8 tool calls, justify overruns.
|
||||
</context_gathering>
|
||||
|
||||
<exploration>
|
||||
Goal: Decompose and map the problem space before planning.
|
||||
Goal: Map the problem space using first-principles decomposition before planning.
|
||||
Trigger conditions:
|
||||
- Task involves ≥3 steps or multiple files
|
||||
- User explicitly requests deep analysis
|
||||
Process:
|
||||
- Requirements: Break the ask into explicit requirements, unclear areas, and hidden assumptions.
|
||||
- Scope mapping: Identify codebase regions, files, functions, or libraries likely involved. If unknown, perform targeted parallel searches NOW before planning. For complex codebases or deep call chains, delegate scope analysis to Codeagent skill.
|
||||
- Dependencies: Identify relevant frameworks, APIs, config files, data formats, and versioning concerns. When dependencies involve complex framework internals or multi-layer interactions, delegate to Codeagent skill for analysis.
|
||||
- Ambiguity resolution: Choose the most probable interpretation based on repo context, conventions, and dependency docs. Document assumptions explicitly.
|
||||
- Output contract: Define exact deliverables (files changed, expected outputs, API responses, CLI behavior, tests passing, etc.).
|
||||
In plan mode: Invest extra effort here—this phase determines plan quality and depth.
|
||||
- Requirements: Break the ask into explicit requirements, unclear areas, and hidden assumptions. Apply <first_principles> step 1 here.
|
||||
- Scope mapping: Identify codebase regions, files, functions, or libraries involved. Perform targeted parallel searches before planning. For complex call chains, delegate to Codeagent skill.
|
||||
- Dependencies: Identify frameworks, APIs, configs, data formats. For complex internals, delegate to Codeagent skill.
|
||||
- Ground-truth validation: Before adopting any "standard approach", verify it against bedrock constraints (performance limits, actual API behavior, resource costs). Apply <first_principles> steps 2-3.
|
||||
- Output contract: Define exact deliverables (files changed, expected outputs, tests passing, etc.).
|
||||
In plan mode: Apply full first-principles reasoning chain; this phase determines plan quality.
|
||||
</exploration>
|
||||
|
||||
<persistence>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# do - Feature Development Orchestrator
|
||||
|
||||
7-phase feature development workflow orchestrating multiple agents via codeagent-wrapper.
|
||||
5-phase feature development workflow orchestrating multiple agents via codeagent-wrapper.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -24,17 +24,15 @@ Examples:
|
||||
/do implement order export to CSV
|
||||
```
|
||||
|
||||
## 7-Phase Workflow
|
||||
## 5-Phase Workflow
|
||||
|
||||
| Phase | Name | Goal | Key Actions |
|
||||
|-------|------|------|-------------|
|
||||
| 1 | Discovery | Understand requirements | AskUserQuestion + code-architect draft |
|
||||
| 2 | Exploration | Map codebase patterns | 2-3 parallel code-explorer tasks |
|
||||
| 3 | Clarification | Resolve ambiguities | **MANDATORY** - must answer before proceeding |
|
||||
| 4 | Architecture | Design implementation | 2 parallel code-architect approaches |
|
||||
| 5 | Implementation | Build the feature | **Requires approval** - develop agent |
|
||||
| 6 | Review | Catch defects | 2-3 parallel code-reviewer tasks |
|
||||
| 7 | Summary | Document results | code-reviewer summary |
|
||||
| 1 | Understand | Gather requirements | AskUserQuestion + code-explorer analysis |
|
||||
| 2 | Clarify | Resolve ambiguities | **MANDATORY** - must answer before proceeding |
|
||||
| 3 | Design | Plan implementation | code-architect approaches |
|
||||
| 4 | Implement | Build the feature | **Requires approval** - develop agent |
|
||||
| 5 | Complete | Finalize and document | code-reviewer summary |
|
||||
|
||||
## Agents
|
||||
|
||||
@@ -50,8 +48,8 @@ To customize agents, create same-named files in `~/.codeagent/agents/` to overri
|
||||
## Hard Constraints
|
||||
|
||||
1. **Never write code directly** - delegate all changes to codeagent-wrapper agents
|
||||
2. **Phase 3 is mandatory** - do not proceed until questions are answered
|
||||
3. **Phase 5 requires approval** - stop after Phase 4 if not approved
|
||||
2. **Phase 2 is mandatory** - do not proceed until questions are answered
|
||||
3. **Phase 4 requires approval** - stop after Phase 3 if not approved
|
||||
4. **Pass complete context forward** - every agent gets the Context Pack
|
||||
5. **Parallel-first** - run independent tasks via `codeagent-wrapper --parallel`
|
||||
6. **Update state after each phase** - keep `.claude/do.{task_id}.local.md` current
|
||||
@@ -63,7 +61,7 @@ To customize agents, create same-named files in `~/.codeagent/agents/` to overri
|
||||
<verbatim request>
|
||||
|
||||
## Context Pack
|
||||
- Phase: <1-7 name>
|
||||
- Phase: <1-5 name>
|
||||
- Decisions: <requirements/constraints/choices>
|
||||
- Code-explorer output: <paste or "None">
|
||||
- Code-architect output: <paste or "None">
|
||||
@@ -83,7 +81,7 @@ To customize agents, create same-named files in `~/.codeagent/agents/` to overri
|
||||
When triggered via `/do <task>`, initializes `.claude/do.{task_id}.local.md` with:
|
||||
- `active: true`
|
||||
- `current_phase: 1`
|
||||
- `max_phases: 7`
|
||||
- `max_phases: 5`
|
||||
- `completion_promise: "<promise>DO_COMPLETE</promise>"`
|
||||
|
||||
After each phase, update frontmatter:
|
||||
@@ -92,7 +90,7 @@ current_phase: <next phase number>
|
||||
phase_name: "<next phase name>"
|
||||
```
|
||||
|
||||
When all 7 phases complete, output:
|
||||
When all 5 phases complete, output:
|
||||
```
|
||||
<promise>DO_COMPLETE</promise>
|
||||
```
|
||||
@@ -158,7 +156,7 @@ EOF
|
||||
|
||||
## ~/.codeagent/models.json Configuration
|
||||
|
||||
Optional. Uses codeagent-wrapper built-in config by default. To customize:
|
||||
Required when using `agent:` in parallel tasks or `--agent`. Create `~/.codeagent/models.json` to configure agent → backend/model mappings:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -184,3 +182,29 @@ Optional. Uses codeagent-wrapper built-in config by default. To customize:
|
||||
```bash
|
||||
python install.py --uninstall --module do
|
||||
```
|
||||
|
||||
## Worktree Mode
|
||||
|
||||
Use `--worktree` to execute tasks in an isolated git worktree, preventing changes to your main branch:
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --worktree --agent develop "implement feature X" .
|
||||
```
|
||||
|
||||
This automatically:
|
||||
1. Generates a unique task ID (format: `YYYYMMDD-xxxxxx`)
|
||||
2. Creates a new worktree at `.worktrees/do-{task_id}/`
|
||||
3. Creates a new branch `do/{task_id}`
|
||||
4. Executes the task in the isolated worktree
|
||||
|
||||
Output includes: `Using worktree: .worktrees/do-{task_id}/ (task_id: {id}, branch: do/{id})`
|
||||
|
||||
In parallel mode, add `worktree: true` to task blocks:
|
||||
```
|
||||
---TASK---
|
||||
id: feature_impl
|
||||
agent: develop
|
||||
worktree: true
|
||||
---CONTENT---
|
||||
Implement the feature
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: do
|
||||
description: This skill should be used for structured feature development with codebase understanding. Triggers on /do command. Provides a 7-phase workflow (Discovery, Exploration, Clarification, Architecture, Implementation, Review, Summary) using codeagent-wrapper to orchestrate code-explorer, code-architect, code-reviewer, and develop agents in parallel.
|
||||
allowed-tools: ["Bash(${SKILL_DIR}/scripts/setup-do.sh:*)"]
|
||||
description: This skill should be used for structured feature development with codebase understanding. Triggers on /do command. Provides a 5-phase workflow (Understand, Clarify, Design, Implement, Complete) using codeagent-wrapper to orchestrate code-explorer, code-architect, code-reviewer, and develop agents in parallel.
|
||||
allowed-tools: ["Bash(${SKILL_DIR}/scripts/setup-do.py:*)"]
|
||||
---
|
||||
|
||||
# do - Feature Development Orchestrator
|
||||
@@ -10,17 +10,57 @@ An orchestrator for systematic feature development. Invoke agents via `codeagent
|
||||
|
||||
## Loop Initialization (REQUIRED)
|
||||
|
||||
When triggered via `/do <task>`, **first** initialize the loop state:
|
||||
When triggered via `/do <task>`, follow these steps:
|
||||
|
||||
### Step 1: Ask about worktree mode
|
||||
|
||||
Use AskUserQuestion to ask:
|
||||
|
||||
```
|
||||
Develop in a separate worktree? (Isolates changes from main branch)
|
||||
- Yes (Recommended for larger changes)
|
||||
- No (Work directly in current directory)
|
||||
```
|
||||
|
||||
### Step 2: Initialize state
|
||||
|
||||
```bash
|
||||
"${SKILL_DIR}/scripts/setup-do.sh" "<task description>"
|
||||
# If worktree mode selected:
|
||||
python3 "${SKILL_DIR}/scripts/setup-do.py" --worktree "<task description>"
|
||||
|
||||
# If no worktree:
|
||||
python3 "${SKILL_DIR}/scripts/setup-do.py" "<task description>"
|
||||
```
|
||||
|
||||
This creates `.claude/do.{task_id}.local.md` with:
|
||||
- `active: true`
|
||||
- `current_phase: 1`
|
||||
- `max_phases: 7`
|
||||
- `max_phases: 5`
|
||||
- `completion_promise: "<promise>DO_COMPLETE</promise>"`
|
||||
- `use_worktree: true/false`
|
||||
|
||||
## Worktree Mode
|
||||
|
||||
When `use_worktree: true` in state file, ALL `codeagent-wrapper` calls that modify code MUST include `--worktree`:
|
||||
|
||||
```bash
|
||||
# With worktree mode enabled
|
||||
codeagent-wrapper --worktree --agent develop - . <<'EOF'
|
||||
...
|
||||
EOF
|
||||
|
||||
# Parallel tasks with worktree
|
||||
codeagent-wrapper --worktree --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: task1
|
||||
agent: develop
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
...
|
||||
EOF
|
||||
```
|
||||
|
||||
The `--worktree` flag tells codeagent-wrapper to create/use a worktree internally. Read-only agents (code-explorer, code-architect, code-reviewer) do NOT need `--worktree`.
|
||||
|
||||
## Loop State Management
|
||||
|
||||
@@ -30,7 +70,7 @@ current_phase: <next phase number>
|
||||
phase_name: "<next phase name>"
|
||||
```
|
||||
|
||||
When all 7 phases complete, output the completion signal:
|
||||
When all 5 phases complete, output the completion signal:
|
||||
```
|
||||
<promise>DO_COMPLETE</promise>
|
||||
```
|
||||
@@ -40,20 +80,35 @@ To abort early, set `active: false` in the state file.
|
||||
## Hard Constraints
|
||||
|
||||
1. **Never write code directly.** Delegate all code changes to `codeagent-wrapper` agents.
|
||||
2. **Phase 3 (Clarification) is mandatory.** Do not proceed until questions are answered.
|
||||
3. **Phase 5 (Implementation) requires explicit approval.** Stop after Phase 4 if not approved.
|
||||
4. **Pass complete context forward.** Every agent invocation includes the Context Pack.
|
||||
5. **Parallel-first.** Run independent tasks via `codeagent-wrapper --parallel`.
|
||||
6. **Update state after each phase.** Keep `.claude/do.{task_id}.local.md` current.
|
||||
2. **Pass complete context forward.** Every agent invocation includes the Context Pack.
|
||||
3. **Parallel-first.** Run independent tasks via `codeagent-wrapper --parallel`.
|
||||
4. **Update state after each phase.** Keep `.claude/do.{task_id}.local.md` current.
|
||||
5. **Expect long-running `codeagent-wrapper` calls.** High-reasoning modes can take a long time; stay in the orchestrator role and wait for agents to complete.
|
||||
6. **Timeouts are not an escape hatch.** If a `codeagent-wrapper` invocation times out/errors, retry (split/narrow the task if needed); never switch to direct implementation.
|
||||
7. **Respect worktree setting.** If `use_worktree: true`, always pass `--worktree` to develop agent calls.
|
||||
|
||||
## Agents
|
||||
|
||||
| Agent | Purpose | Prompt |
|
||||
|-------|---------|--------|
|
||||
| `code-explorer` | Trace code, map architecture, find patterns | `agents/code-explorer.md` |
|
||||
| `code-architect` | Design approaches, file plans, build sequences | `agents/code-architect.md` |
|
||||
| `code-reviewer` | Review for bugs, simplicity, conventions | `agents/code-reviewer.md` |
|
||||
| `develop` | Implement code, run tests | (uses global config) |
|
||||
| Agent | Purpose | Needs --worktree |
|
||||
|-------|---------|------------------|
|
||||
| `code-explorer` | Trace code, map architecture, find patterns | No (read-only) |
|
||||
| `code-architect` | Design approaches, file plans, build sequences | No (read-only) |
|
||||
| `code-reviewer` | Review for bugs, simplicity, conventions | No (read-only) |
|
||||
| `develop` | Implement code, run tests | **Yes** (if worktree enabled) |
|
||||
|
||||
## Issue Severity Definitions
|
||||
|
||||
**Blocking issues** (require user input):
|
||||
- Impacts core functionality or correctness
|
||||
- Security vulnerabilities
|
||||
- Architectural conflicts with existing patterns
|
||||
- Ambiguous requirements with multiple valid interpretations
|
||||
|
||||
**Minor issues** (auto-fix without asking):
|
||||
- Code style inconsistencies
|
||||
- Naming improvements
|
||||
- Missing documentation
|
||||
- Non-critical test coverage gaps
|
||||
|
||||
## Context Pack Template
|
||||
|
||||
@@ -62,7 +117,7 @@ To abort early, set `active: false` in the state file.
|
||||
<verbatim request>
|
||||
|
||||
## Context Pack
|
||||
- Phase: <1-7 name>
|
||||
- Phase: <1-5 name>
|
||||
- Decisions: <requirements/constraints/choices>
|
||||
- Code-explorer output: <paste or "None">
|
||||
- Code-architect output: <paste or "None">
|
||||
@@ -77,18 +132,21 @@ To abort early, set `active: false` in the state file.
|
||||
<checkable outputs>
|
||||
```
|
||||
|
||||
## 7-Phase Workflow
|
||||
## 5-Phase Workflow
|
||||
|
||||
### Phase 1: Discovery
|
||||
### Phase 1: Understand (Parallel, No Interaction)
|
||||
|
||||
**Goal:** Understand what to build.
|
||||
**Goal:** Understand requirements and map codebase simultaneously.
|
||||
|
||||
**Actions:**
|
||||
1. Use AskUserQuestion for: user-visible behavior, scope, constraints, acceptance criteria
|
||||
2. Invoke `code-architect` to draft requirements checklist and clarifying questions
|
||||
**Actions:** Run `code-architect` and 2-3 `code-explorer` tasks in parallel.
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --agent code-architect - . <<'EOF'
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: p1_requirements
|
||||
agent: code-architect
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
@@ -97,33 +155,29 @@ codeagent-wrapper --agent code-architect - . <<'EOF'
|
||||
- Code-architect output: None
|
||||
|
||||
## Current Task
|
||||
Produce requirements checklist and identify missing information.
|
||||
Output: Requirements, Non-goals, Risks, Acceptance criteria, Questions (<= 10)
|
||||
1. Analyze requirements completeness (score 1-10)
|
||||
2. Extract explicit requirements, constraints, acceptance criteria
|
||||
3. Identify blocking questions (issues that prevent implementation)
|
||||
4. Identify minor clarifications (nice-to-have but can proceed without)
|
||||
|
||||
Output format:
|
||||
- Completeness score: X/10
|
||||
- Requirements: [list]
|
||||
- Non-goals: [list]
|
||||
- Blocking questions: [list, if any]
|
||||
- Minor clarifications: [list, if any]
|
||||
|
||||
## Acceptance Criteria
|
||||
Concrete, testable checklist; specific questions; no implementation.
|
||||
EOF
|
||||
```
|
||||
Concrete checklist; blocking vs minor questions clearly separated.
|
||||
|
||||
### Phase 2: Exploration
|
||||
|
||||
**Goal:** Map codebase patterns and extension points.
|
||||
|
||||
**Actions:** Run 2-3 `code-explorer` tasks in parallel (similar features, architecture, tests/conventions).
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: p2_similar_features
|
||||
id: p1_similar_features
|
||||
agent: code-explorer
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-architect output: <Phase 1 output>
|
||||
|
||||
## Current Task
|
||||
Find 1-3 similar features, trace end-to-end. Return: key files with line numbers, call flow, extension points.
|
||||
|
||||
@@ -131,16 +185,13 @@ Find 1-3 similar features, trace end-to-end. Return: key files with line numbers
|
||||
Concrete file:line map + reuse points.
|
||||
|
||||
---TASK---
|
||||
id: p2_architecture
|
||||
id: p1_architecture
|
||||
agent: code-explorer
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-architect output: <Phase 1 output>
|
||||
|
||||
## Current Task
|
||||
Map architecture for relevant subsystem. Return: module map + 5-10 key files.
|
||||
|
||||
@@ -148,16 +199,13 @@ Map architecture for relevant subsystem. Return: module map + 5-10 key files.
|
||||
Clear boundaries; file:line references.
|
||||
|
||||
---TASK---
|
||||
id: p2_conventions
|
||||
id: p1_conventions
|
||||
agent: code-explorer
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-architect output: <Phase 1 output>
|
||||
|
||||
## Current Task
|
||||
Identify testing patterns, conventions, config. Return: test commands + file locations.
|
||||
|
||||
@@ -166,86 +214,74 @@ Test commands + relevant test file paths.
|
||||
EOF
|
||||
```
|
||||
|
||||
### Phase 3: Clarification (MANDATORY)
|
||||
### Phase 2: Clarify (Conditional)
|
||||
|
||||
**Goal:** Resolve all ambiguities before design.
|
||||
**Goal:** Resolve blocking ambiguities only.
|
||||
|
||||
**Actions:**
|
||||
1. Invoke `code-architect` to generate prioritized questions from Phase 1+2 outputs
|
||||
2. Use AskUserQuestion to present questions and wait for answers
|
||||
3. **Do not proceed until answered or defaults accepted**
|
||||
|
||||
### Phase 4: Architecture
|
||||
|
||||
**Goal:** Produce implementation plan fitting existing patterns.
|
||||
|
||||
**Actions:** Run 2 `code-architect` tasks in parallel (minimal-change vs pragmatic-clean).
|
||||
1. Review `p1_requirements` output for blocking questions
|
||||
2. **IF blocking questions exist** → Use AskUserQuestion
|
||||
3. **IF no blocking questions (completeness >= 8)** → Skip to Phase 3, log "Requirements clear, proceeding"
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: p4_minimal
|
||||
agent: code-architect
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
# Only if blocking questions exist:
|
||||
# Use AskUserQuestion with the blocking questions from Phase 1
|
||||
```
|
||||
|
||||
### Phase 3: Design (No Interaction)
|
||||
|
||||
**Goal:** Produce minimal-change implementation plan.
|
||||
|
||||
**Actions:** Invoke `code-architect` with all Phase 1 context to generate a single implementation plan.
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --agent code-architect - . <<'EOF'
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-explorer output: <ALL Phase 2 outputs>
|
||||
- Code-architect output: <Phase 1 + Phase 3 answers>
|
||||
- Code-explorer output: <ALL Phase 1 explorer outputs>
|
||||
- Code-architect output: <Phase 1 requirements + Phase 2 answers if any>
|
||||
|
||||
## Current Task
|
||||
Propose minimal-change architecture: reuse existing abstractions, minimize new files.
|
||||
Output: file touch list, risks, edge cases.
|
||||
Design minimal-change implementation:
|
||||
- Reuse existing abstractions
|
||||
- Minimize new files
|
||||
- Follow established patterns from code-explorer output
|
||||
|
||||
Output:
|
||||
- File touch list with specific changes
|
||||
- Build sequence
|
||||
- Test plan
|
||||
- Risks and mitigations
|
||||
|
||||
## Acceptance Criteria
|
||||
Concrete blueprint; minimal moving parts.
|
||||
|
||||
---TASK---
|
||||
id: p4_pragmatic
|
||||
agent: code-architect
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-explorer output: <ALL Phase 2 outputs>
|
||||
- Code-architect output: <Phase 1 + Phase 3 answers>
|
||||
|
||||
## Current Task
|
||||
Propose pragmatic-clean architecture: introduce seams for testability.
|
||||
Output: file touch list, testing plan, risks.
|
||||
|
||||
## Acceptance Criteria
|
||||
Implementable blueprint with build sequence and tests.
|
||||
Concrete, implementable blueprint with minimal moving parts.
|
||||
EOF
|
||||
```
|
||||
|
||||
Use AskUserQuestion to let user choose approach.
|
||||
### Phase 4: Implement + Review (Single Interaction Point)
|
||||
|
||||
### Phase 5: Implementation (Approval Required)
|
||||
|
||||
**Goal:** Build the feature.
|
||||
**Goal:** Build feature and review in one phase.
|
||||
|
||||
**Actions:**
|
||||
1. Use AskUserQuestion: "Approve starting implementation?" (Approve / Not yet)
|
||||
2. If approved, invoke `develop`:
|
||||
|
||||
1. Invoke `develop` to implement (add `--worktree` if `use_worktree: true`):
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --agent develop - . <<'EOF'
|
||||
# Check use_worktree from state file, add --worktree if true
|
||||
codeagent-wrapper --worktree --agent develop - . <<'EOF'
|
||||
## Original User Request
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-explorer output: <ALL Phase 2 outputs>
|
||||
- Code-architect output: <selected Phase 4 blueprint + Phase 3 answers>
|
||||
- Code-explorer output: <ALL Phase 1 outputs>
|
||||
- Code-architect output: <Phase 3 blueprint>
|
||||
|
||||
## Current Task
|
||||
Implement with minimal change set following chosen architecture.
|
||||
- Follow Phase 2 patterns
|
||||
- Add/adjust tests per Phase 4 plan
|
||||
Implement with minimal change set following the blueprint.
|
||||
- Follow Phase 1 patterns
|
||||
- Add/adjust tests per Phase 3 plan
|
||||
- Run narrowest relevant tests
|
||||
|
||||
## Acceptance Criteria
|
||||
@@ -253,16 +289,12 @@ Feature works end-to-end; tests pass; diff is minimal.
|
||||
EOF
|
||||
```
|
||||
|
||||
### Phase 6: Review
|
||||
|
||||
**Goal:** Catch defects and unnecessary complexity.
|
||||
|
||||
**Actions:** Run 2-3 `code-reviewer` tasks in parallel (correctness, simplicity).
|
||||
2. Run parallel reviews (no --worktree needed, read-only):
|
||||
|
||||
```bash
|
||||
codeagent-wrapper --parallel <<'EOF'
|
||||
---TASK---
|
||||
id: p6_correctness
|
||||
id: p4_correctness
|
||||
agent: code-reviewer
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
@@ -270,17 +302,18 @@ workdir: .
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-architect output: <Phase 4 blueprint>
|
||||
- Develop output: <Phase 5 output>
|
||||
- Code-architect output: <Phase 3 blueprint>
|
||||
- Develop output: <implementation output>
|
||||
|
||||
## Current Task
|
||||
Review for correctness, edge cases, failure modes. Assume adversarial inputs.
|
||||
Review for correctness, edge cases, failure modes.
|
||||
Classify each issue as BLOCKING or MINOR.
|
||||
|
||||
## Acceptance Criteria
|
||||
Issues with file:line references and concrete fixes.
|
||||
Issues with file:line references, severity, and concrete fixes.
|
||||
|
||||
---TASK---
|
||||
id: p6_simplicity
|
||||
id: p4_simplicity
|
||||
agent: code-reviewer
|
||||
workdir: .
|
||||
---CONTENT---
|
||||
@@ -288,20 +321,23 @@ workdir: .
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-architect output: <Phase 4 blueprint>
|
||||
- Develop output: <Phase 5 output>
|
||||
- Code-architect output: <Phase 3 blueprint>
|
||||
- Develop output: <implementation output>
|
||||
|
||||
## Current Task
|
||||
Review for KISS: remove bloat, collapse needless abstractions.
|
||||
Classify each issue as BLOCKING or MINOR.
|
||||
|
||||
## Acceptance Criteria
|
||||
Actionable simplifications with justification.
|
||||
Actionable simplifications with severity and justification.
|
||||
EOF
|
||||
```
|
||||
|
||||
Use AskUserQuestion: Fix now / Fix later / Proceed as-is.
|
||||
3. Handle review results:
|
||||
- **MINOR issues only** → Auto-fix via `develop` (with `--worktree` if enabled), no user interaction
|
||||
- **BLOCKING issues** → Use AskUserQuestion: "Fix now / Proceed as-is"
|
||||
|
||||
### Phase 7: Summary
|
||||
### Phase 5: Complete (No Interaction)
|
||||
|
||||
**Goal:** Document what was built.
|
||||
|
||||
@@ -313,9 +349,9 @@ codeagent-wrapper --agent code-reviewer - . <<'EOF'
|
||||
/do <request>
|
||||
|
||||
## Context Pack
|
||||
- Code-architect output: <Phase 4 blueprint>
|
||||
- Code-reviewer output: <Phase 6 outcomes>
|
||||
- Develop output: <Phase 5 output + fixes>
|
||||
- Code-architect output: <Phase 3 blueprint>
|
||||
- Code-reviewer output: <Phase 4 review outcomes>
|
||||
- Develop output: <Phase 4 implementation + fixes>
|
||||
|
||||
## Current Task
|
||||
Write completion summary:
|
||||
@@ -329,3 +365,8 @@ Write completion summary:
|
||||
Short, technical, actionable summary.
|
||||
EOF
|
||||
```
|
||||
|
||||
Output the completion signal:
|
||||
```
|
||||
<promise>DO_COMPLETE</promise>
|
||||
```
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"description": "do loop hook for 7-phase workflow",
|
||||
"description": "do loop hook for 5-phase workflow",
|
||||
"hooks": {
|
||||
"Stop": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.sh"
|
||||
"command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/stop-hook.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
131
skills/do/hooks/stop-hook.py
Executable file
131
skills/do/hooks/stop-hook.py
Executable file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
PHASE_NAMES = {
|
||||
1: "Understand",
|
||||
2: "Clarify",
|
||||
3: "Design",
|
||||
4: "Implement",
|
||||
5: "Complete",
|
||||
}
|
||||
|
||||
def phase_name_for(n: int) -> str:
|
||||
return PHASE_NAMES.get(n, f"Phase {n}")
|
||||
|
||||
def frontmatter_get(file_path: str, key: str) -> str:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
lines = f.readlines()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
if not lines or lines[0].strip() != "---":
|
||||
return ""
|
||||
|
||||
for i, line in enumerate(lines[1:], start=1):
|
||||
if line.strip() == "---":
|
||||
break
|
||||
match = re.match(rf"^{re.escape(key)}:\s*(.*)$", line)
|
||||
if match:
|
||||
value = match.group(1).strip()
|
||||
if value.startswith('"') and value.endswith('"'):
|
||||
value = value[1:-1]
|
||||
return value
|
||||
return ""
|
||||
|
||||
def get_body(file_path: str) -> str:
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return ""
|
||||
|
||||
def check_state_file(state_file: str, stdin_payload: str) -> str:
|
||||
active_raw = frontmatter_get(state_file, "active")
|
||||
active_lc = active_raw.lower()
|
||||
if active_lc not in ("true", "1", "yes", "on"):
|
||||
return ""
|
||||
|
||||
current_phase_raw = frontmatter_get(state_file, "current_phase")
|
||||
max_phases_raw = frontmatter_get(state_file, "max_phases")
|
||||
phase_name = frontmatter_get(state_file, "phase_name")
|
||||
completion_promise = frontmatter_get(state_file, "completion_promise")
|
||||
|
||||
try:
|
||||
current_phase = int(current_phase_raw)
|
||||
except (ValueError, TypeError):
|
||||
current_phase = 1
|
||||
|
||||
try:
|
||||
max_phases = int(max_phases_raw)
|
||||
except (ValueError, TypeError):
|
||||
max_phases = 5
|
||||
|
||||
if not phase_name:
|
||||
phase_name = phase_name_for(current_phase)
|
||||
|
||||
if not completion_promise:
|
||||
completion_promise = "<promise>DO_COMPLETE</promise>"
|
||||
|
||||
phases_done = current_phase >= max_phases
|
||||
|
||||
if phases_done:
|
||||
# 阶段已完成,清理状态文件并允许退出
|
||||
# promise 检测作为可选确认,不阻止退出
|
||||
try:
|
||||
os.remove(state_file)
|
||||
except Exception:
|
||||
pass
|
||||
return ""
|
||||
|
||||
return (f"do loop incomplete: current phase {current_phase}/{max_phases} ({phase_name}). "
|
||||
f"Continue with remaining phases; update {state_file} current_phase/phase_name after each phase. "
|
||||
f"Include completion_promise in final output when done: {completion_promise}. "
|
||||
f"To exit early, set active to false.")
|
||||
|
||||
def main():
|
||||
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
||||
state_dir = os.path.join(project_dir, ".claude")
|
||||
|
||||
do_task_id = os.environ.get("DO_TASK_ID", "")
|
||||
|
||||
if do_task_id:
|
||||
candidate = os.path.join(state_dir, f"do.{do_task_id}.local.md")
|
||||
state_files = [candidate] if os.path.isfile(candidate) else []
|
||||
else:
|
||||
state_files = glob.glob(os.path.join(state_dir, "do.*.local.md"))
|
||||
|
||||
if not state_files:
|
||||
sys.exit(0)
|
||||
|
||||
stdin_payload = ""
|
||||
if not sys.stdin.isatty():
|
||||
try:
|
||||
stdin_payload = sys.stdin.read()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
blocking_reasons = []
|
||||
for state_file in state_files:
|
||||
reason = check_state_file(state_file, stdin_payload)
|
||||
if reason:
|
||||
blocking_reasons.append(reason)
|
||||
|
||||
if not blocking_reasons:
|
||||
sys.exit(0)
|
||||
|
||||
combined_reason = " ".join(blocking_reasons)
|
||||
print(json.dumps({"decision": "block", "reason": combined_reason}))
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,151 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
phase_name_for() {
|
||||
case "${1:-}" in
|
||||
1) echo "Discovery" ;;
|
||||
2) echo "Exploration" ;;
|
||||
3) echo "Clarification" ;;
|
||||
4) echo "Architecture" ;;
|
||||
5) echo "Implementation" ;;
|
||||
6) echo "Review" ;;
|
||||
7) echo "Summary" ;;
|
||||
*) echo "Phase ${1:-unknown}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="${1:-}"
|
||||
s=${s//\\/\\\\}
|
||||
s=${s//\"/\\\"}
|
||||
s=${s//$'\n'/\\n}
|
||||
s=${s//$'\r'/\\r}
|
||||
s=${s//$'\t'/\\t}
|
||||
printf "%s" "$s"
|
||||
}
|
||||
|
||||
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
state_dir="${project_dir}/.claude"
|
||||
|
||||
shopt -s nullglob
|
||||
state_files=("${state_dir}"/do.*.local.md)
|
||||
shopt -u nullglob
|
||||
|
||||
if [ ${#state_files[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
stdin_payload=""
|
||||
if [ ! -t 0 ]; then
|
||||
stdin_payload="$(cat || true)"
|
||||
fi
|
||||
|
||||
frontmatter_get() {
|
||||
local file="$1" key="$2"
|
||||
awk -v k="$key" '
|
||||
BEGIN { in_fm=0 }
|
||||
NR==1 && $0=="---" { in_fm=1; next }
|
||||
in_fm==1 && $0=="---" { exit }
|
||||
in_fm==1 {
|
||||
if ($0 ~ "^"k":[[:space:]]*") {
|
||||
sub("^"k":[[:space:]]*", "", $0)
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
|
||||
if ($0 ~ /^".*"$/) { sub(/^"/, "", $0); sub(/"$/, "", $0) }
|
||||
print $0
|
||||
exit
|
||||
}
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
check_state_file() {
|
||||
local state_file="$1"
|
||||
|
||||
local active_raw active_lc
|
||||
active_raw="$(frontmatter_get "$state_file" active || true)"
|
||||
active_lc="$(printf "%s" "$active_raw" | tr '[:upper:]' '[:lower:]')"
|
||||
case "$active_lc" in
|
||||
true|1|yes|on) ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
|
||||
local current_phase_raw max_phases_raw phase_name completion_promise
|
||||
current_phase_raw="$(frontmatter_get "$state_file" current_phase || true)"
|
||||
max_phases_raw="$(frontmatter_get "$state_file" max_phases || true)"
|
||||
phase_name="$(frontmatter_get "$state_file" phase_name || true)"
|
||||
completion_promise="$(frontmatter_get "$state_file" completion_promise || true)"
|
||||
|
||||
local current_phase=1
|
||||
if [[ "${current_phase_raw:-}" =~ ^[0-9]+$ ]]; then
|
||||
current_phase="$current_phase_raw"
|
||||
fi
|
||||
|
||||
local max_phases=7
|
||||
if [[ "${max_phases_raw:-}" =~ ^[0-9]+$ ]]; then
|
||||
max_phases="$max_phases_raw"
|
||||
fi
|
||||
|
||||
if [ -z "${phase_name:-}" ]; then
|
||||
phase_name="$(phase_name_for "$current_phase")"
|
||||
fi
|
||||
|
||||
if [ -z "${completion_promise:-}" ]; then
|
||||
completion_promise="<promise>DO_COMPLETE</promise>"
|
||||
fi
|
||||
|
||||
local phases_done=0
|
||||
if [ "$current_phase" -ge "$max_phases" ]; then
|
||||
phases_done=1
|
||||
fi
|
||||
|
||||
local promise_met=0
|
||||
if [ -n "$completion_promise" ]; then
|
||||
if [ -n "$stdin_payload" ] && printf "%s" "$stdin_payload" | grep -Fq -- "$completion_promise"; then
|
||||
promise_met=1
|
||||
else
|
||||
local body
|
||||
body="$(
|
||||
awk '
|
||||
BEGIN { in_fm=0; body=0 }
|
||||
NR==1 && $0=="---" { in_fm=1; next }
|
||||
in_fm==1 && $0=="---" { body=1; in_fm=0; next }
|
||||
body==1 { print }
|
||||
' "$state_file"
|
||||
)"
|
||||
if [ -n "$body" ] && printf "%s" "$body" | grep -Fq -- "$completion_promise"; then
|
||||
promise_met=1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$phases_done" -eq 1 ] && [ "$promise_met" -eq 1 ]; then
|
||||
rm -f "$state_file"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local reason
|
||||
if [ "$phases_done" -eq 0 ]; then
|
||||
reason="do loop incomplete: current phase ${current_phase}/${max_phases} (${phase_name}). Continue with remaining phases; update ${state_file} current_phase/phase_name after each phase. Include completion_promise in final output when done: ${completion_promise}. To exit early, set active to false."
|
||||
else
|
||||
reason="do reached final phase (current_phase=${current_phase} / max_phases=${max_phases}, phase_name=${phase_name}), but completion_promise not detected: ${completion_promise}. Please include this marker in your final output (or write it to ${state_file} body), then finish; to force exit, set active to false."
|
||||
fi
|
||||
|
||||
printf "%s" "$reason"
|
||||
}
|
||||
|
||||
blocking_reasons=()
|
||||
for state_file in "${state_files[@]}"; do
|
||||
reason="$(check_state_file "$state_file")"
|
||||
if [ -n "$reason" ]; then
|
||||
blocking_reasons+=("$reason")
|
||||
fi
|
||||
done
|
||||
|
||||
if [ ${#blocking_reasons[@]} -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
combined_reason="${blocking_reasons[*]}"
|
||||
printf '{"decision":"block","reason":"%s"}\n' "$(json_escape "$combined_reason")"
|
||||
exit 0
|
||||
164
skills/do/install.py
Executable file
164
skills/do/install.py
Executable file
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install/uninstall do skill to ~/.claude/skills/do"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SKILL_NAME = "do"
|
||||
HOOK_PATH = "~/.claude/skills/do/hooks/stop-hook.py"
|
||||
|
||||
MODELS_JSON_TEMPLATE = {
|
||||
"agents": {
|
||||
"code-explorer": {
|
||||
"backend": "claude",
|
||||
"model": "claude-sonnet-4-5-20250929"
|
||||
},
|
||||
"code-architect": {
|
||||
"backend": "claude",
|
||||
"model": "claude-sonnet-4-5-20250929"
|
||||
},
|
||||
"code-reviewer": {
|
||||
"backend": "claude",
|
||||
"model": "claude-sonnet-4-5-20250929"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
def get_settings_path() -> Path:
|
||||
return Path.home() / ".claude" / "settings.json"
|
||||
|
||||
def load_settings() -> dict:
|
||||
path = get_settings_path()
|
||||
if path.exists():
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_settings(settings: dict):
|
||||
path = get_settings_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(settings, f, indent=2)
|
||||
|
||||
def add_hook(settings: dict) -> dict:
|
||||
hook_command = str(Path(HOOK_PATH).expanduser())
|
||||
hook_entry = {
|
||||
"type": "command",
|
||||
"command": f"python3 {hook_command}"
|
||||
}
|
||||
|
||||
if "hooks" not in settings:
|
||||
settings["hooks"] = {}
|
||||
if "Stop" not in settings["hooks"]:
|
||||
settings["hooks"]["Stop"] = []
|
||||
|
||||
stop_hooks = settings["hooks"]["Stop"]
|
||||
|
||||
for item in stop_hooks:
|
||||
if "hooks" in item:
|
||||
for h in item["hooks"]:
|
||||
if "stop-hook" in h.get("command", "") and "do" in h.get("command", ""):
|
||||
h["command"] = f"python3 {hook_command}"
|
||||
return settings
|
||||
|
||||
stop_hooks.append({"hooks": [hook_entry]})
|
||||
return settings
|
||||
|
||||
def remove_hook(settings: dict) -> dict:
|
||||
if "hooks" not in settings or "Stop" not in settings["hooks"]:
|
||||
return settings
|
||||
|
||||
stop_hooks = settings["hooks"]["Stop"]
|
||||
new_stop_hooks = []
|
||||
|
||||
for item in stop_hooks:
|
||||
if "hooks" in item:
|
||||
filtered = [h for h in item["hooks"]
|
||||
if "stop-hook" not in h.get("command", "")
|
||||
or "do" not in h.get("command", "")]
|
||||
if filtered:
|
||||
item["hooks"] = filtered
|
||||
new_stop_hooks.append(item)
|
||||
else:
|
||||
new_stop_hooks.append(item)
|
||||
|
||||
settings["hooks"]["Stop"] = new_stop_hooks
|
||||
if not settings["hooks"]["Stop"]:
|
||||
del settings["hooks"]["Stop"]
|
||||
if not settings["hooks"]:
|
||||
del settings["hooks"]
|
||||
|
||||
return settings
|
||||
|
||||
def install_models_json():
|
||||
"""Install ~/.codeagent/models.json if not exists"""
|
||||
path = Path.home() / ".codeagent" / "models.json"
|
||||
if path.exists():
|
||||
print(f"⚠ {path} already exists, skipping")
|
||||
return
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(MODELS_JSON_TEMPLATE, f, indent=2)
|
||||
print(f"✓ Created {path}")
|
||||
|
||||
def install():
|
||||
src = Path(__file__).parent.resolve()
|
||||
dest = Path.home() / ".claude" / "skills" / SKILL_NAME
|
||||
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
exclude = {".git", "__pycache__", ".DS_Store", "install.py"}
|
||||
|
||||
for item in src.iterdir():
|
||||
if item.name in exclude:
|
||||
continue
|
||||
target = dest / item.name
|
||||
if target.exists():
|
||||
if target.is_dir():
|
||||
shutil.rmtree(target)
|
||||
else:
|
||||
target.unlink()
|
||||
if item.is_dir():
|
||||
shutil.copytree(item, target)
|
||||
else:
|
||||
shutil.copy2(item, target)
|
||||
|
||||
settings = load_settings()
|
||||
settings = add_hook(settings)
|
||||
save_settings(settings)
|
||||
|
||||
install_models_json()
|
||||
|
||||
print(f"✓ Installed to {dest}")
|
||||
print(f"✓ Hook added to settings.json")
|
||||
|
||||
def uninstall():
|
||||
dest = Path.home() / ".claude" / "skills" / SKILL_NAME
|
||||
|
||||
settings = load_settings()
|
||||
settings = remove_hook(settings)
|
||||
save_settings(settings)
|
||||
print(f"✓ Hook removed from settings.json")
|
||||
|
||||
if dest.exists():
|
||||
shutil.rmtree(dest)
|
||||
print(f"✓ Removed {dest}")
|
||||
else:
|
||||
print(f"⚠ {dest} not found")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Install/uninstall do skill")
|
||||
parser.add_argument("--uninstall", "-u", action="store_true", help="Uninstall the skill")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.uninstall:
|
||||
uninstall()
|
||||
else:
|
||||
install()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
85
skills/do/scripts/setup-do.py
Executable file
85
skills/do/scripts/setup-do.py
Executable file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import time
|
||||
|
||||
PHASE_NAMES = {
|
||||
1: "Understand",
|
||||
2: "Clarify",
|
||||
3: "Design",
|
||||
4: "Implement",
|
||||
5: "Complete",
|
||||
}
|
||||
|
||||
def phase_name_for(n: int) -> str:
|
||||
return PHASE_NAMES.get(n, f"Phase {n}")
|
||||
|
||||
def die(msg: str):
|
||||
print(f"❌ {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Creates (or overwrites) project state file: .claude/do.local.md"
|
||||
)
|
||||
parser.add_argument("--max-phases", type=int, default=5, help="Default: 5")
|
||||
parser.add_argument(
|
||||
"--completion-promise",
|
||||
default="<promise>DO_COMPLETE</promise>",
|
||||
help="Default: <promise>DO_COMPLETE</promise>",
|
||||
)
|
||||
parser.add_argument("--worktree", action="store_true", help="Enable worktree mode")
|
||||
parser.add_argument("prompt", nargs="+", help="Task description")
|
||||
args = parser.parse_args()
|
||||
|
||||
max_phases = args.max_phases
|
||||
completion_promise = args.completion_promise
|
||||
use_worktree = args.worktree
|
||||
prompt = " ".join(args.prompt)
|
||||
|
||||
if max_phases < 1:
|
||||
die("--max-phases must be a positive integer")
|
||||
|
||||
project_dir = os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())
|
||||
state_dir = os.path.join(project_dir, ".claude")
|
||||
|
||||
task_id = f"{int(time.time())}-{os.getpid()}-{secrets.token_hex(4)}"
|
||||
state_file = os.path.join(state_dir, f"do.{task_id}.local.md")
|
||||
|
||||
os.makedirs(state_dir, exist_ok=True)
|
||||
|
||||
phase_name = phase_name_for(1)
|
||||
|
||||
content = f"""---
|
||||
active: true
|
||||
current_phase: 1
|
||||
phase_name: "{phase_name}"
|
||||
max_phases: {max_phases}
|
||||
completion_promise: "{completion_promise}"
|
||||
use_worktree: {str(use_worktree).lower()}
|
||||
---
|
||||
|
||||
# do loop state
|
||||
|
||||
## Prompt
|
||||
{prompt}
|
||||
|
||||
## Notes
|
||||
- Update frontmatter current_phase/phase_name as you progress
|
||||
- When complete, include the frontmatter completion_promise in your final output
|
||||
"""
|
||||
|
||||
with open(state_file, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"Initialized: {state_file}")
|
||||
print(f"task_id: {task_id}")
|
||||
print(f"phase: 1/{max_phases} ({phase_name})")
|
||||
print(f"completion_promise: {completion_promise}")
|
||||
print(f"use_worktree: {use_worktree}")
|
||||
print(f"export DO_TASK_ID={task_id}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,114 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: setup-do.sh [options] PROMPT...
|
||||
|
||||
Creates (or overwrites) project state file:
|
||||
.claude/do.local.md
|
||||
|
||||
Options:
|
||||
--max-phases N Default: 7
|
||||
--completion-promise STR Default: <promise>DO_COMPLETE</promise>
|
||||
-h, --help Show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
echo "❌ $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
phase_name_for() {
|
||||
case "${1:-}" in
|
||||
1) echo "Discovery" ;;
|
||||
2) echo "Exploration" ;;
|
||||
3) echo "Clarification" ;;
|
||||
4) echo "Architecture" ;;
|
||||
5) echo "Implementation" ;;
|
||||
6) echo "Review" ;;
|
||||
7) echo "Summary" ;;
|
||||
*) echo "Phase ${1:-unknown}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
max_phases=7
|
||||
completion_promise="<promise>DO_COMPLETE</promise>"
|
||||
declare -a prompt_parts=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--max-phases)
|
||||
[ $# -ge 2 ] || die "--max-phases requires a value"
|
||||
max_phases="$2"
|
||||
shift 2
|
||||
;;
|
||||
--completion-promise)
|
||||
[ $# -ge 2 ] || die "--completion-promise requires a value"
|
||||
completion_promise="$2"
|
||||
shift 2
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
while [ $# -gt 0 ]; do
|
||||
prompt_parts+=("$1")
|
||||
shift
|
||||
done
|
||||
break
|
||||
;;
|
||||
-*)
|
||||
die "Unknown argument: $1 (use --help)"
|
||||
;;
|
||||
*)
|
||||
prompt_parts+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
prompt="${prompt_parts[*]:-}"
|
||||
[ -n "$prompt" ] || die "PROMPT is required (use --help)"
|
||||
|
||||
if ! [[ "$max_phases" =~ ^[0-9]+$ ]] || [ "$max_phases" -lt 1 ]; then
|
||||
die "--max-phases must be a positive integer"
|
||||
fi
|
||||
|
||||
project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
state_dir="${project_dir}/.claude"
|
||||
|
||||
task_id="$(date +%s)-$$-$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')"
|
||||
state_file="${state_dir}/do.${task_id}.local.md"
|
||||
|
||||
mkdir -p "$state_dir"
|
||||
|
||||
phase_name="$(phase_name_for 1)"
|
||||
|
||||
cat > "$state_file" << EOF
|
||||
---
|
||||
active: true
|
||||
current_phase: 1
|
||||
phase_name: "$phase_name"
|
||||
max_phases: $max_phases
|
||||
completion_promise: "$completion_promise"
|
||||
---
|
||||
|
||||
# do loop state
|
||||
|
||||
## Prompt
|
||||
$prompt
|
||||
|
||||
## Notes
|
||||
- Update frontmatter current_phase/phase_name as you progress
|
||||
- When complete, include the frontmatter completion_promise in your final output
|
||||
EOF
|
||||
|
||||
echo "Initialized: $state_file"
|
||||
echo "task_id: $task_id"
|
||||
echo "phase: 1/$max_phases ($phase_name)"
|
||||
echo "completion_promise: $completion_promise"
|
||||
Reference in New Issue
Block a user