Compare commits

...

6 Commits

Author SHA1 Message Date
cexll
716d1eb173 fix(do): isolate stop hook by task_id to prevent concurrent task interference
When running multiple do tasks concurrently in worktrees, the stop hook
would scan all do.*.local.md files and block exit for unrelated tasks.

Changes:
- setup-do.sh: export DO_TASK_ID for hook environment
- stop-hook.sh: filter state files by DO_TASK_ID when set, fallback to
  scanning all files for backward compatibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 16:01:24 +08:00
cexll
4bc9ffa907 fix(cli): resolve process hang after install and sync version with tag
- Add process.stdin.pause() in cleanup() to properly exit event loop
- Pass tag via CODEAGENT_WRAPPER_VERSION env to install.sh
- Support versioned release URL in install.sh

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-28 15:08:24 +08:00
cexll
c6c2f93e02 fix(codeagent-wrapper): skip tmpdir tests on Windows
ensureExecutableTempDir is intentionally no-op on Windows,
so tests should be skipped on that platform.

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-28 13:10:42 +08:00
cexll
cd3115446d fix(codeagent-wrapper): improve CI, version handling and temp dir
- CI: fetch tags for version detection
- Makefile: inject version via ldflags
- Add CODEAGENT_TMPDIR support for macOS permission issues
- Inject ANTHROPIC_BASE_URL/API_KEY for claude backend

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-28 11:55:55 +08:00
cexll
2b8bfd714c feat(install): add uninstall command and merge_dir file tracking
- JS: add uninstall subcommand with --module and -y options
- JS: merge hooks to settings.json after module install
- Python: record merge_dir files for reversible uninstall
- Both: track installed files in installed_modules.json

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-28 11:55:55 +08:00
cexll
71485558df fix(do): add timeout handling constraints for codeagent-wrapper
Closes #138

- Add constraint 7: expect long-running codeagent-wrapper calls
- Add constraint 8: timeouts are not an escape hatch, must retry

Generated with SWE-Agent.ai

Co-Authored-By: SWE-Agent.ai <noreply@swe-agent.ai>
2026-01-28 10:09:32 +08:00
18 changed files with 793 additions and 55 deletions

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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

View File

@@ -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`),请确认代理进程在运行。

View File

@@ -9,8 +9,9 @@ import (
"time"
)
var version = "dev"
const (
version = "6.1.2"
defaultWorkdir = "."
defaultTimeout = 7200 // seconds (2 hours)
defaultCoverageTarget = 90.0

View File

@@ -168,6 +168,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)

View 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
}

View 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)
}
}
}

View File

@@ -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
}

View File

@@ -72,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"])
}
}
@@ -149,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

View File

@@ -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")

View File

@@ -117,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)
}
}

View File

@@ -1088,6 +1088,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 != "" {
@@ -1397,6 +1399,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"

View File

@@ -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:

View File

@@ -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

View File

@@ -45,6 +45,8 @@ To abort early, set `active: false` in the state file.
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.
7. **Expect long-running `codeagent-wrapper` calls.** High-reasoning modes (e.g. `xhigh`) can take a long time; stay in the orchestrator role and wait for agents to complete.
8. **Timeouts are not an escape hatch.** If a `codeagent-wrapper` invocation times out/errors, retry `codeagent-wrapper` (split/narrow the task if needed); never switch to direct implementation.
## Agents

View File

@@ -29,7 +29,16 @@ project_dir="${CLAUDE_PROJECT_DIR:-$PWD}"
state_dir="${project_dir}/.claude"
shopt -s nullglob
state_files=("${state_dir}"/do.*.local.md)
if [ -n "${DO_TASK_ID:-}" ]; then
candidate="${state_dir}/do.${DO_TASK_ID}.local.md"
if [ -f "$candidate" ]; then
state_files=("$candidate")
else
state_files=()
fi
else
state_files=("${state_dir}"/do.*.local.md)
fi
shopt -u nullglob
if [ ${#state_files[@]} -eq 0 ]; then

View File

@@ -112,3 +112,4 @@ echo "Initialized: $state_file"
echo "task_id: $task_id"
echo "phase: 1/$max_phases ($phase_name)"
echo "completion_promise: $completion_promise"
echo "export DO_TASK_ID=$task_id"