diff --git a/bin/cli.js b/bin/cli.js index 3b9fc3e..e9afde1 100755 --- a/bin/cli.js +++ b/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 Default: ~/.claude", @@ -59,6 +72,8 @@ function printHelp() { " --list List installable items and exit", " --update Update already installed modules", " --tag Install a specific GitHub tag", + " --module 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); + upsertModuleStatus(installDir, r); } } finally { if (tmp) await rmTree(tmp); @@ -513,11 +710,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 +732,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,9 +742,11 @@ 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) { @@ -577,33 +778,154 @@ async function applyModule(moduleName, config, repoRoot, installDir, force) { 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); + } 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) { @@ -647,7 +969,8 @@ async function installSelected(picks, tag, config, installDir, force, dryRun) { } 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); + upsertModuleStatus(installDir, r); continue; } if (p.kind === "skill") { @@ -672,8 +995,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 { diff --git a/install.py b/install.py index 00e6747..4fe6fc7 100644 --- a/install.py +++ b/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: