diff --git a/bin/cli.js b/bin/cli.js index 98e390a..fdf32c6 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -15,6 +15,8 @@ const API_HEADERS = { "User-Agent": "myclaude-npx", Accept: "application/vnd.github+json", }; +const WRAPPER_REQUIRED_MODULES = new Set(["do", "omo"]); +const WRAPPER_REQUIRED_SKILLS = new Set(["dev"]); function parseArgs(argv) { const out = { @@ -499,9 +501,19 @@ async function updateInstalledModules(installDir, tag, config, dryRun) { } await fs.promises.mkdir(installDir, { recursive: true }); + const installState = { wrapperInstalled: false }; + + async function ensureWrapperInstalled() { + if (installState.wrapperInstalled) return; + process.stdout.write("Installing codeagent-wrapper...\n"); + await runInstallSh(repoRoot, installDir, tag); + installState.wrapperInstalled = true; + } + for (const name of toUpdate) { + if (WRAPPER_REQUIRED_MODULES.has(name)) await ensureWrapperInstalled(); process.stdout.write(`Updating module: ${name}\n`); - const r = await applyModule(name, config, repoRoot, installDir, true, tag); + const r = await applyModule(name, config, repoRoot, installDir, true, tag, installState); upsertModuleStatus(installDir, r); } } finally { @@ -777,7 +789,57 @@ async function rmTree(p) { await fs.promises.rmdir(p, { recursive: true }); } -async function applyModule(moduleName, config, repoRoot, installDir, force, tag) { +function defaultModelsConfig() { + return { + default_backend: "codex", + default_model: "gpt-4.1", + backends: {}, + agents: {}, + }; +} + +function mergeModuleAgentsToModels(moduleName, mod, repoRoot) { + const moduleAgents = mod && mod.agents; + if (!isPlainObject(moduleAgents) || !Object.keys(moduleAgents).length) return false; + + const modelsPath = path.join(os.homedir(), ".codeagent", "models.json"); + fs.mkdirSync(path.dirname(modelsPath), { recursive: true }); + + let models; + if (fs.existsSync(modelsPath)) { + models = JSON.parse(fs.readFileSync(modelsPath, "utf8")); + } else { + const templatePath = path.join(repoRoot, "templates", "models.json.example"); + if (fs.existsSync(templatePath)) { + models = JSON.parse(fs.readFileSync(templatePath, "utf8")); + if (!isPlainObject(models)) models = defaultModelsConfig(); + models.agents = {}; + } else { + models = defaultModelsConfig(); + } + } + + if (!isPlainObject(models)) models = defaultModelsConfig(); + if (!isPlainObject(models.agents)) models.agents = {}; + + let modified = false; + for (const [agentName, agentCfg] of Object.entries(moduleAgents)) { + if (!isPlainObject(agentCfg)) continue; + const existing = models.agents[agentName]; + const canOverwrite = !isPlainObject(existing) || Object.prototype.hasOwnProperty.call(existing, "__module__"); + if (!canOverwrite) continue; + const next = { ...agentCfg, __module__: moduleName }; + if (!deepEqual(existing, next)) { + models.agents[agentName] = next; + modified = true; + } + } + + if (modified) fs.writeFileSync(modelsPath, JSON.stringify(models, null, 2) + "\n", "utf8"); + return modified; +} + +async function applyModule(moduleName, config, repoRoot, installDir, force, tag, installState) { const mod = config && config.modules && config.modules[moduleName]; if (!mod) throw new Error(`Unknown module: ${moduleName}`); const ops = Array.isArray(mod.operations) ? mod.operations : []; @@ -803,7 +865,12 @@ async function applyModule(moduleName, config, repoRoot, installDir, force, tag) if (cmd !== "bash install.sh") { throw new Error(`Refusing run_command: ${cmd || "(empty)"}`); } + if (installState && installState.wrapperInstalled) { + result.operations.push({ type, status: "success", skipped: true }); + continue; + } await runInstallSh(repoRoot, installDir, tag); + if (installState) installState.wrapperInstalled = true; } else { throw new Error(`Unsupported operation type: ${type}`); } @@ -834,6 +901,19 @@ async function applyModule(moduleName, config, repoRoot, installDir, force, tag) }); } + try { + if (mergeModuleAgentsToModels(moduleName, mod, repoRoot)) { + result.has_agents = true; + result.operations.push({ type: "merge_agents", status: "success" }); + } + } catch (err) { + result.operations.push({ + type: "merge_agents", + status: "failed", + error: err && err.message ? err.message : String(err), + }); + } + return result; } @@ -1023,20 +1103,37 @@ async function installSelected(picks, tag, config, installDir, force, dryRun) { } await fs.promises.mkdir(installDir, { recursive: true }); + const installState = { wrapperInstalled: false }; + + async function ensureWrapperInstalled() { + if (installState.wrapperInstalled) return; + process.stdout.write("Installing codeagent-wrapper...\n"); + await runInstallSh(repoRoot, installDir, tag); + installState.wrapperInstalled = true; + } for (const p of picks) { if (p.kind === "wrapper") { - process.stdout.write("Installing codeagent-wrapper...\n"); - await runInstallSh(repoRoot, installDir, tag); + await ensureWrapperInstalled(); continue; } if (p.kind === "module") { + if (WRAPPER_REQUIRED_MODULES.has(p.moduleName)) await ensureWrapperInstalled(); process.stdout.write(`Installing module: ${p.moduleName}\n`); - const r = await applyModule(p.moduleName, config, repoRoot, installDir, force, tag); + const r = await applyModule( + p.moduleName, + config, + repoRoot, + installDir, + force, + tag, + installState + ); upsertModuleStatus(installDir, r); continue; } if (p.kind === "skill") { + if (WRAPPER_REQUIRED_SKILLS.has(p.skillName)) await ensureWrapperInstalled(); process.stdout.write(`Installing skill: ${p.skillName}\n`); await copyDirRecursive( path.join(repoRoot, "skills", p.skillName), diff --git a/install.py b/install.py index 05c55f7..7df9cf7 100644 --- a/install.py +++ b/install.py @@ -24,6 +24,7 @@ except ImportError: # pragma: no cover DEFAULT_INSTALL_DIR = "~/.claude" SETTINGS_FILE = "settings.json" +WRAPPER_REQUIRED_MODULES = {"do", "omo"} def _ensure_list(ctx: Dict[str, Any], key: str) -> List[Any]: @@ -898,6 +899,24 @@ def execute_module(name: str, cfg: Dict[str, Any], ctx: Dict[str, Any]) -> Dict[ "installed_at": datetime.now().isoformat(), } + if name in WRAPPER_REQUIRED_MODULES: + try: + ensure_wrapper_installed(ctx) + result["operations"].append({"type": "ensure_wrapper", "status": "success"}) + except Exception as exc: # noqa: BLE001 + result["status"] = "failed" + result["operations"].append( + {"type": "ensure_wrapper", "status": "failed", "error": str(exc)} + ) + write_log( + { + "level": "ERROR", + "message": f"Module {name} failed on ensure_wrapper: {exc}", + }, + ctx, + ) + raise + for op in cfg.get("operations", []): op_type = op.get("type") try: @@ -1081,8 +1100,13 @@ def op_run_command(op: Dict[str, Any], ctx: Dict[str, Any]) -> None: for key, value in op.get("env", {}).items(): env[key] = value.replace("${install_dir}", str(ctx["install_dir"])) - command = op.get("command", "") - if sys.platform == "win32" and command.strip() == "bash install.sh": + raw_command = str(op.get("command", "")).strip() + if raw_command == "bash install.sh" and ctx.get("_wrapper_installed"): + write_log({"level": "INFO", "message": "Skip wrapper install; already installed in this run"}, ctx) + return + + command = raw_command + if sys.platform == "win32" and raw_command == "bash install.sh": command = "cmd /c install.bat" # Stream output in real-time while capturing for logging @@ -1156,6 +1180,22 @@ def op_run_command(op: Dict[str, Any], ctx: Dict[str, Any]) -> None: if process.returncode != 0: raise RuntimeError(f"Command failed with code {process.returncode}: {command}") + if raw_command == "bash install.sh": + ctx["_wrapper_installed"] = True + + +def ensure_wrapper_installed(ctx: Dict[str, Any]) -> None: + if ctx.get("_wrapper_installed"): + return + op_run_command( + { + "type": "run_command", + "command": "bash install.sh", + "env": {"INSTALL_DIR": "${install_dir}"}, + }, + ctx, + ) + def write_log(entry: Dict[str, Any], ctx: Dict[str, Any]) -> None: log_path = Path(ctx["log_file"])