auriga-cli 1.15.2 → 1.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -102,6 +102,16 @@ export function parseArgs(argv) {
102
102
  }
103
103
  return { command: "guide" };
104
104
  }
105
+ if (head === "web-ui") {
106
+ try {
107
+ return { command: "web-ui", ui: parseUi(argv.slice(1)) };
108
+ }
109
+ catch (e) {
110
+ if (e instanceof PerTypeHelpRequest)
111
+ return { command: "help" };
112
+ throw e;
113
+ }
114
+ }
105
115
  if (head !== "install") {
106
116
  parseErr(`unknown argument '${head}'. Run 'npx auriga-cli --help' for usage.`);
107
117
  }
@@ -115,6 +125,43 @@ export function parseArgs(argv) {
115
125
  throw e;
116
126
  }
117
127
  }
128
+ function parseUi(argv) {
129
+ const out = {};
130
+ let i = 0;
131
+ while (i < argv.length) {
132
+ const t = argv[i];
133
+ if (t === "--help" || t === "-h") {
134
+ // ui --help routes to the top-level help — keeps the parser narrow
135
+ // and avoids a per-command help renderer for one subcommand.
136
+ throw new PerTypeHelpRequest(undefined);
137
+ }
138
+ if (t === "--port" || t.startsWith("--port=")) {
139
+ const [v, adv] = readSingleValue(argv, i, "--port");
140
+ const n = Number.parseInt(v, 10);
141
+ // 0 is a deliberate "OS-assigned ephemeral port" affordance used by
142
+ // hermetic e2e tests (spec §8.1). Real users pass a normal port.
143
+ if (!Number.isFinite(n) || n < 0 || n > 65535) {
144
+ parseErr(`--port must be a port number in [0, 65535], got '${v}'`);
145
+ }
146
+ out.port = n;
147
+ i += adv;
148
+ continue;
149
+ }
150
+ if (t === "--ui-dir" || t.startsWith("--ui-dir=")) {
151
+ const [v, adv] = readSingleValue(argv, i, "--ui-dir");
152
+ out.uiDir = v;
153
+ i += adv;
154
+ continue;
155
+ }
156
+ if (t === "--no-open") {
157
+ out.noOpen = true;
158
+ i += 1;
159
+ continue;
160
+ }
161
+ parseErr(`unknown argument '${t}' for 'web-ui'. Run 'npx auriga-cli --help' for usage.`);
162
+ }
163
+ return out;
164
+ }
118
165
  function parseInstall(argv) {
119
166
  const out = { all: false };
120
167
  let filterFlag = null;
@@ -326,6 +373,9 @@ export async function main(argv) {
326
373
  process.stdout.write(renderGuide({ color, version }));
327
374
  return 0;
328
375
  }
376
+ if (parsed.command === "web-ui") {
377
+ return runUi(parsed.ui, version);
378
+ }
329
379
  // install — catalog is required for filter validation and for the TTY
330
380
  // menu's category descriptions; fail-fast at entry rather than produce
331
381
  // a cryptic error mid-dispatch (spec §7 / §11 acceptance).
@@ -508,6 +558,176 @@ async function dispatchInstaller(category, packageRoot, opts) {
508
558
  }
509
559
  }
510
560
  // ---------------------------------------------------------------------------
561
+ // `web-ui` subcommand — boots the local Web UI server (spec §4)
562
+ // ---------------------------------------------------------------------------
563
+ const UI_DEFAULT_PORT = 4747;
564
+ const UI_PORT_RANGE = 10; // 4747..4756
565
+ const UI_HEARTBEAT_TIMEOUT_MS = 15_000;
566
+ async function runUi(p, version) {
567
+ // Lazy-load the server-side deps so the install / guide paths stay light.
568
+ const { randomBytes } = await import("node:crypto");
569
+ const { startServer } = await import("./server.js");
570
+ const { buildDefaultApplyHandlers } = await import("./apply-handlers.js");
571
+ const { buildScanCatalog } = await import("./scan-catalog.js");
572
+ const { ensureUiBundle } = await import("./ui-fetch.js");
573
+ const cwd = process.cwd();
574
+ const packageRoot = getPackageRoot();
575
+ // 1. Resolve UI bundle directory.
576
+ let uiDir;
577
+ if (p.uiDir) {
578
+ uiDir = path.resolve(p.uiDir);
579
+ if (!fs.existsSync(path.join(uiDir, "index.html"))) {
580
+ log.error(`--ui-dir does not contain index.html: ${uiDir}`);
581
+ return 3;
582
+ }
583
+ }
584
+ else if (process.env.DEV === "1") {
585
+ // Dev convenience: prefer the locally-built ui/dist over a network fetch.
586
+ const localDist = path.join(packageRoot, "ui", "dist");
587
+ if (fs.existsSync(path.join(localDist, "index.html"))) {
588
+ uiDir = localDist;
589
+ }
590
+ else {
591
+ log.error("DEV mode: ui/dist not built. Run 'npm --prefix ui run build' or unset DEV to fetch from GitHub.");
592
+ return 3;
593
+ }
594
+ }
595
+ else {
596
+ try {
597
+ uiDir = await ensureUiBundle({
598
+ version,
599
+ onLog: (line) => log.ok(line),
600
+ });
601
+ }
602
+ catch (e) {
603
+ log.error(`Failed to fetch UI bundle: ${e.message}\n` +
604
+ ` Try again or pass --ui-dir <path> with a locally-built bundle.`);
605
+ return 3;
606
+ }
607
+ }
608
+ // 2. Build scan catalog → ApplyCatalog + pluginAgentsByName.
609
+ let scanCatalog;
610
+ try {
611
+ scanCatalog = await buildScanCatalog(packageRoot);
612
+ }
613
+ catch (e) {
614
+ log.error(`Failed to build catalog: ${e.message}`);
615
+ return 1;
616
+ }
617
+ const applyCatalog = {
618
+ // Workflow is a singleton (one CLAUDE.md per project); we pick the
619
+ // sentinel name "workflow" to match what the Web UI's Dashboard sends
620
+ // and to remain semantically self-describing. The handler ignores the
621
+ // name argument either way.
622
+ workflow: new Set(["workflow"]),
623
+ skill: new Set(Object.keys(scanCatalog.skills)),
624
+ "recommended-skill": new Set(Object.keys(scanCatalog.recommendedSkills)),
625
+ plugin: new Set(Object.keys(scanCatalog.plugins)),
626
+ hook: new Set(Object.keys(scanCatalog.hooks)),
627
+ };
628
+ const pluginAgentsByName = new Map();
629
+ for (const [name, def] of Object.entries(scanCatalog.plugins)) {
630
+ pluginAgentsByName.set(name, def.agents);
631
+ }
632
+ const applyHandlers = buildDefaultApplyHandlers({
633
+ packageRoot,
634
+ cwd,
635
+ pluginAgentsByName,
636
+ });
637
+ // 3. Token: 32 bytes hex per spec §4.4.
638
+ const token = randomBytes(32).toString("hex");
639
+ // 4. Bind port: try requested → otherwise 4747..4756 in sequence.
640
+ // Use `!== undefined` so `--port 0` (OS-ephemeral) is honored. `0` is
641
+ // falsy in JS; `p.port ? [p.port] : range` would silently fall back to
642
+ // the default range and break hermetic e2e isolation.
643
+ const ports = p.port !== undefined
644
+ ? [p.port]
645
+ : Array.from({ length: UI_PORT_RANGE }, (_, i) => UI_DEFAULT_PORT + i);
646
+ let server = null;
647
+ let lastErr = null;
648
+ for (const port of ports) {
649
+ try {
650
+ server = await startServer({
651
+ port,
652
+ token,
653
+ cwd,
654
+ packageRoot,
655
+ heartbeatTimeoutMs: UI_HEARTBEAT_TIMEOUT_MS,
656
+ applyHandlers,
657
+ applyCatalog,
658
+ uiDir,
659
+ });
660
+ break;
661
+ }
662
+ catch (e) {
663
+ lastErr = e;
664
+ // Only swallow address-in-use; everything else propagates.
665
+ if (!/EADDRINUSE|EACCES/i.test(lastErr.message)) {
666
+ log.error(`Failed to start server on port ${port}: ${lastErr.message}`);
667
+ return 1;
668
+ }
669
+ }
670
+ }
671
+ if (!server) {
672
+ log.error(`All ports occupied in range (${ports[0]}..${ports[ports.length - 1]}). ` +
673
+ `Try '--port <n>' or 'npx auriga-cli' for the TTY menu. Last error: ${lastErr?.message ?? "unknown"}`);
674
+ return 2;
675
+ }
676
+ // 5. URL + browser open.
677
+ const url = `http://127.0.0.1:${server.port}/?token=${token}`;
678
+ process.stdout.write(`\n${highlight("auriga UI is live:")} ${url}\n` +
679
+ ` (closing the browser shuts the server down after ~${Math.round(UI_HEARTBEAT_TIMEOUT_MS / 1000)}s of inactivity)\n` +
680
+ ` Note: the URL contains a per-session token — don't paste it into chats, CI logs, or screenshots.\n\n`);
681
+ if (!p.noOpen) {
682
+ await openBrowser(url);
683
+ }
684
+ // 6. Block until the server fully stops. The heartbeat closes it after
685
+ // UI_HEARTBEAT_TIMEOUT_MS without a /api/ping; SIGINT triggers an
686
+ // explicit close().
687
+ const onSig = () => {
688
+ void server.close();
689
+ };
690
+ process.once("SIGINT", onSig);
691
+ process.once("SIGTERM", onSig);
692
+ try {
693
+ await server.closed;
694
+ }
695
+ finally {
696
+ process.off("SIGINT", onSig);
697
+ process.off("SIGTERM", onSig);
698
+ }
699
+ return 0;
700
+ }
701
+ /** Best-effort cross-platform browser open. Failure is non-fatal — the
702
+ * printed URL is still actionable. */
703
+ async function openBrowser(url) {
704
+ const opener = process.platform === "darwin"
705
+ ? ["open", [url]]
706
+ : process.platform === "win32"
707
+ ? ["cmd", ["/c", "start", "", url]]
708
+ : ["xdg-open", [url]];
709
+ try {
710
+ const { spawn } = await import("node:child_process");
711
+ const proc = spawn(opener[0], opener[1], {
712
+ stdio: "ignore",
713
+ detached: true,
714
+ });
715
+ proc.on("error", () => {
716
+ /* swallow: URL was already printed */
717
+ });
718
+ proc.unref();
719
+ }
720
+ catch {
721
+ /* swallow */
722
+ }
723
+ }
724
+ /** Bold + cyan when stdout is a TTY; otherwise plain. */
725
+ function highlight(text) {
726
+ if (!process.stdout.isTTY || process.env.NO_COLOR)
727
+ return text;
728
+ return `\x1b[1;36m${text}\x1b[0m`;
729
+ }
730
+ // ---------------------------------------------------------------------------
511
731
  // Legacy checkbox menu — preserved for `npx auriga-cli install` in TTY
512
732
  // and `npx auriga-cli` with no args.
513
733
  // ---------------------------------------------------------------------------
package/dist/help.js CHANGED
@@ -15,6 +15,8 @@ USAGE
15
15
  (excludes recommended — install separately)
16
16
  npx auriga-cli install <type> [type-specific flags] single category
17
17
  npx auriga-cli install <type> --help per-category help + catalog subset
18
+ npx auriga-cli web-ui [--port <n>] [--ui-dir <path>] [--no-open]
19
+ open the local Web UI (spec §4)
18
20
  npx auriga-cli --help
19
21
 
20
22
  For non-interactive (Agent) use, prepend npx's own -y flag:
package/dist/hooks.d.ts CHANGED
@@ -203,4 +203,34 @@ export declare function resolveHookSelection(compatible: HookDef[], selected: st
203
203
  */
204
204
  export declare function findIncompatibleExplicit(all: HookDef[], compatible: HookDef[], selected: string[]): string[];
205
205
  export declare function installHooks(packageRoot: string, opts: InstallOpts): Promise<void>;
206
+ /**
207
+ * Uninstall a single hook. Defaults to project scope; explicit
208
+ * `scope:"user"` cleans `~/.claude/...` instead.
209
+ *
210
+ * Project scope (default):
211
+ * - rm `<cwd>/.claude/hooks/<name>/` directory.
212
+ * - Strip the hook's marker from `<cwd>/.claude/settings.json` AND
213
+ * `<cwd>/.claude/settings.local.json` (project + project-local share
214
+ * the on-disk hook dir, so cleaning both settings files keeps users
215
+ * who switched scopes from accumulating dangling registrations).
216
+ *
217
+ * User scope:
218
+ * - rm `~/.claude/hooks/<name>/` directory.
219
+ * - Strip the hook's marker from `~/.claude/settings.json`.
220
+ * - Project files are NOT touched.
221
+ *
222
+ * Marker discovery: tries the live registry at `<cwd>` (or the npx
223
+ * package root if that fails) so we use the same marker the install path
224
+ * stamped in. If the registry can't resolve the hook (renamed / removed
225
+ * upstream), we fall back to a `auriga:<name>` convention — every shipped
226
+ * hook to date follows it, so the fallback is reliable for the common
227
+ * case.
228
+ *
229
+ * Idempotent: missing hook dir / missing settings / absent marker → no-op.
230
+ */
231
+ export declare function uninstallHook(name: string, opts: {
232
+ cwd: string;
233
+ scope?: "project" | "user";
234
+ onLog?: (line: string) => void;
235
+ }): Promise<void>;
206
236
  export {};
package/dist/hooks.js CHANGED
@@ -868,3 +868,92 @@ export async function installHooks(packageRoot, opts) {
868
868
  throw new Error(`${failures.length} hook(s) failed to install: ${failures.join(", ")}`);
869
869
  }
870
870
  }
871
+ // --- Uninstall ----------------------------------------------------------------
872
+ const HOOK_NAME_RE_STRICT = /^[a-z][a-z0-9-]*$/;
873
+ /**
874
+ * Uninstall a single hook. Defaults to project scope; explicit
875
+ * `scope:"user"` cleans `~/.claude/...` instead.
876
+ *
877
+ * Project scope (default):
878
+ * - rm `<cwd>/.claude/hooks/<name>/` directory.
879
+ * - Strip the hook's marker from `<cwd>/.claude/settings.json` AND
880
+ * `<cwd>/.claude/settings.local.json` (project + project-local share
881
+ * the on-disk hook dir, so cleaning both settings files keeps users
882
+ * who switched scopes from accumulating dangling registrations).
883
+ *
884
+ * User scope:
885
+ * - rm `~/.claude/hooks/<name>/` directory.
886
+ * - Strip the hook's marker from `~/.claude/settings.json`.
887
+ * - Project files are NOT touched.
888
+ *
889
+ * Marker discovery: tries the live registry at `<cwd>` (or the npx
890
+ * package root if that fails) so we use the same marker the install path
891
+ * stamped in. If the registry can't resolve the hook (renamed / removed
892
+ * upstream), we fall back to a `auriga:<name>` convention — every shipped
893
+ * hook to date follows it, so the fallback is reliable for the common
894
+ * case.
895
+ *
896
+ * Idempotent: missing hook dir / missing settings / absent marker → no-op.
897
+ */
898
+ export async function uninstallHook(name, opts) {
899
+ if (!HOOK_NAME_RE_STRICT.test(name)) {
900
+ throw new Error(`uninstallHook: invalid hook name ${JSON.stringify(name)}`);
901
+ }
902
+ const cwd = path.resolve(opts.cwd);
903
+ const scope = opts.scope ?? "project";
904
+ const emit = (line) => { opts.onLog?.(line); };
905
+ // Look up marker from the registry. If the registry is absent or the
906
+ // hook isn't listed, fall back to `auriga:<name>` (the shipped naming
907
+ // convention for every hook in this repo).
908
+ let marker = `auriga:${name}`;
909
+ try {
910
+ const cfg = loadHooksConfig(cwd);
911
+ const def = cfg.hooks.find((h) => h.name === name);
912
+ if (def)
913
+ marker = def.marker;
914
+ }
915
+ catch {
916
+ // No registry at cwd — fine, fall back to the convention.
917
+ }
918
+ // Build a minimal HookDef stub for cleanHookFromScope. It only reads
919
+ // `marker` and `name` (via resolveScope), both of which we have.
920
+ const stub = {
921
+ name,
922
+ description: "",
923
+ runtimePlatforms: [],
924
+ settingsEvents: [],
925
+ command: 'node "$HOOK_DIR/index.mjs"',
926
+ files: [],
927
+ marker,
928
+ };
929
+ // resolveScope picks the settings/hooks paths based on scope.
930
+ // Project scope covers both project + project-local settings (shared
931
+ // on-disk hook dir); user scope covers only ~/.claude/settings.json.
932
+ const cleanScopes = scope === "user" ? ["user"] : ["project", "project-local"];
933
+ let totalRemoved = 0;
934
+ for (const s of cleanScopes) {
935
+ const r = cleanHookFromScope(stub, s, cwd);
936
+ if (r.removed > 0) {
937
+ totalRemoved += r.removed;
938
+ log.ok(`${name}: removed ${r.removed} entr${r.removed === 1 ? "y" : "ies"} from ${r.settingsPath}`);
939
+ emit(`removed ${r.removed} entr${r.removed === 1 ? "y" : "ies"} from ${r.settingsPath}`);
940
+ }
941
+ }
942
+ if (totalRemoved === 0) {
943
+ log.skip(`${name}: no settings entries found`);
944
+ emit(`${name}: no settings entries found`);
945
+ }
946
+ // Hook directory: user → ~/.claude/hooks/<name>; project → <cwd>/.claude/hooks/<name>.
947
+ const hookDir = scope === "user"
948
+ ? path.join(os.homedir(), ".claude", "hooks", name)
949
+ : path.join(cwd, ".claude", "hooks", name);
950
+ if (fs.existsSync(hookDir)) {
951
+ fs.rmSync(hookDir, { recursive: true, force: true });
952
+ log.ok(`${name}: directory removed`);
953
+ emit(`removed ${hookDir}`);
954
+ }
955
+ else {
956
+ log.skip(`${name}: directory not present`);
957
+ emit(`${name}: directory not present`);
958
+ }
959
+ }
package/dist/plugins.d.ts CHANGED
@@ -1,3 +1,32 @@
1
1
  import type { InstallOpts, PluginsConfig } from "./utils.js";
2
2
  export declare function validatePluginsConfig(raw: unknown): asserts raw is PluginsConfig;
3
3
  export declare function installPlugins(packageRoot: string, opts: InstallOpts): Promise<void>;
4
+ /**
5
+ * Uninstall a single plugin.
6
+ *
7
+ * Claude side: shells out to `claude plugins uninstall <id>` (the
8
+ * canonical CLI path). Errors are propagated — the CLI sometimes
9
+ * surfaces nuanced failure modes (marketplace gone, network) that
10
+ * the caller needs to see verbatim.
11
+ *
12
+ * Codex side: no `codex plugin uninstall` exists today (spec §10.4
13
+ * flagged this as v0.1 needs-confirm). We mimic the install path
14
+ * in reverse:
15
+ * 1. Read + parse `~/.codex/config.toml`, delete `[plugins."<id>"]`,
16
+ * atomic write back. Throws on parse error (don't half-corrupt).
17
+ * 2. rm `~/.codex/plugins/cache/<marketplace>/<plugin>/` directory.
18
+ * Both steps are idempotent — missing config / missing cache dir is
19
+ * a no-op (the user may have manually cleaned half of the install).
20
+ *
21
+ * Caveat: we deliberately do NOT remove the marketplace itself. A
22
+ * single marketplace may host multiple plugins; tearing it down
23
+ * because one plugin left would break others. The user can
24
+ * `codex plugin marketplace remove` separately when they want.
25
+ *
26
+ * Validation happens before any I/O — a malformed id throws cleanly with
27
+ * no side effects, so retries are safe.
28
+ */
29
+ export declare function uninstallPlugin(id: string, agent: "claude" | "codex", opts: {
30
+ cwd: string;
31
+ onLog?: (line: string) => void;
32
+ }): Promise<void>;
package/dist/plugins.js CHANGED
@@ -5,7 +5,7 @@ import { checkbox, select } from "@inquirer/prompts";
5
5
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
6
6
  import { codexManifestPath, validateCodexInstallConfig, validateCodexMarketplace, } from "./codex-plugin-config.js";
7
7
  import { validateMarketplaceField } from "./marketplace.js";
8
- import { atomicWriteFile, exec, fetchExtraContent, log, withEsc } from "./utils.js";
8
+ import { atomicWriteFile, exec, execAsync, fetchExtraContent, log, withEsc } from "./utils.js";
9
9
  // Plugin names and plugin-package names end up in `claude plugins ...`
10
10
  // shell commands via string interpolation. .claude/plugins.json is
11
11
  // fetched from raw GitHub at runtime, so every value must pass a
@@ -531,7 +531,14 @@ export async function installPlugins(packageRoot, opts) {
531
531
  for (const [name, source] of marketplacesToAdd) {
532
532
  console.log(`\nAdding marketplace: ${name}...`);
533
533
  try {
534
- exec(`claude plugins marketplace add ${source}`, { inherit: true });
534
+ const cmd = `claude plugins marketplace add ${source}`;
535
+ if (opts.onLog) {
536
+ opts.onLog(`▸ ${cmd}`, "stdout");
537
+ await execAsync(cmd, { onLine: opts.onLog });
538
+ }
539
+ else {
540
+ exec(cmd, { inherit: true });
541
+ }
535
542
  log.ok(`Marketplace ${name} added`);
536
543
  }
537
544
  catch {
@@ -542,7 +549,14 @@ export async function installPlugins(packageRoot, opts) {
542
549
  for (const name of marketplacesToUpdate) {
543
550
  console.log(`\nUpdating marketplace: ${name}...`);
544
551
  try {
545
- exec(`claude plugins marketplace update ${name}`, { inherit: true });
552
+ const cmd = `claude plugins marketplace update ${name}`;
553
+ if (opts.onLog) {
554
+ opts.onLog(`▸ ${cmd}`, "stdout");
555
+ await execAsync(cmd, { onLine: opts.onLog });
556
+ }
557
+ else {
558
+ exec(cmd, { inherit: true });
559
+ }
546
560
  log.ok(`Marketplace ${name} updated`);
547
561
  }
548
562
  catch (e) {
@@ -557,9 +571,14 @@ export async function installPlugins(packageRoot, opts) {
557
571
  for (const plugin of selected) {
558
572
  console.log(`\nInstalling ${plugin.name}...`);
559
573
  try {
560
- exec(`claude plugins install ${plugin.package} --scope ${scope}`, {
561
- inherit: true,
562
- });
574
+ const cmd = `claude plugins install ${plugin.package} --scope ${scope}`;
575
+ if (opts.onLog) {
576
+ opts.onLog(`▸ ${cmd}`, "stdout");
577
+ await execAsync(cmd, { onLine: opts.onLog });
578
+ }
579
+ else {
580
+ exec(cmd, { inherit: true });
581
+ }
563
582
  log.ok(`${plugin.name} installed`);
564
583
  }
565
584
  catch {
@@ -584,3 +603,115 @@ export async function installPlugins(packageRoot, opts) {
584
603
  }
585
604
  }
586
605
  }
606
+ // --- Uninstall ----------------------------------------------------------------
607
+ // Plugin id format: `<plugin>@<marketplace>` (matches the Codex config.toml
608
+ // key shape and Claude Code's `claude plugins install ...` argument).
609
+ // Tightened with the same name regex used everywhere else in this file
610
+ // (PLUGIN_NAME_RE on the plugin side, MARKETPLACE_NAME_RE on the
611
+ // marketplace side, both anchored). `name` is interpolated into a shell
612
+ // command (Claude path) and used as a filesystem segment (Codex path);
613
+ // rejecting unsafe shapes here closes both attack surfaces in one place.
614
+ const PLUGIN_ID_RE = /^([A-Za-z0-9][A-Za-z0-9._-]{0,127})@([A-Za-z0-9][A-Za-z0-9._-]{0,127})$/;
615
+ function parsePluginId(id) {
616
+ const m = PLUGIN_ID_RE.exec(id);
617
+ if (!m) {
618
+ throw new Error(`uninstallPlugin: invalid plugin id ${JSON.stringify(id)}; expected <plugin>@<marketplace>`);
619
+ }
620
+ return { plugin: m[1], marketplace: m[2] };
621
+ }
622
+ /**
623
+ * Remove `[plugins."<id>"]` from a parsed Codex config TOML tree.
624
+ * Returns true if anything was removed. Idempotent: missing key → false.
625
+ *
626
+ * Pure function operating on the parsed tree — no I/O. Lets the test
627
+ * harness assert tree shape without touching disk + lets the I/O wrapper
628
+ * skip the atomic write when nothing changed.
629
+ */
630
+ function removeCodexPluginFromConfig(parsed, pluginId) {
631
+ const plugins = parsed.plugins;
632
+ if (!isTomlTable(plugins))
633
+ return false;
634
+ if (!(pluginId in plugins))
635
+ return false;
636
+ delete plugins[pluginId];
637
+ return true;
638
+ }
639
+ /**
640
+ * Uninstall a single plugin.
641
+ *
642
+ * Claude side: shells out to `claude plugins uninstall <id>` (the
643
+ * canonical CLI path). Errors are propagated — the CLI sometimes
644
+ * surfaces nuanced failure modes (marketplace gone, network) that
645
+ * the caller needs to see verbatim.
646
+ *
647
+ * Codex side: no `codex plugin uninstall` exists today (spec §10.4
648
+ * flagged this as v0.1 needs-confirm). We mimic the install path
649
+ * in reverse:
650
+ * 1. Read + parse `~/.codex/config.toml`, delete `[plugins."<id>"]`,
651
+ * atomic write back. Throws on parse error (don't half-corrupt).
652
+ * 2. rm `~/.codex/plugins/cache/<marketplace>/<plugin>/` directory.
653
+ * Both steps are idempotent — missing config / missing cache dir is
654
+ * a no-op (the user may have manually cleaned half of the install).
655
+ *
656
+ * Caveat: we deliberately do NOT remove the marketplace itself. A
657
+ * single marketplace may host multiple plugins; tearing it down
658
+ * because one plugin left would break others. The user can
659
+ * `codex plugin marketplace remove` separately when they want.
660
+ *
661
+ * Validation happens before any I/O — a malformed id throws cleanly with
662
+ * no side effects, so retries are safe.
663
+ */
664
+ export async function uninstallPlugin(id, agent, opts) {
665
+ const { plugin, marketplace } = parsePluginId(id);
666
+ const emit = (line) => { opts.onLog?.(line); };
667
+ if (agent === "claude") {
668
+ // Note: scope is intentionally NOT specified. `claude plugins
669
+ // uninstall <id>` operates against whatever scope the plugin is
670
+ // installed in (user / project) — letting the CLI find it is
671
+ // more robust than guessing wrong and silently no-op'ing.
672
+ exec(`claude plugins uninstall ${id}`, { cwd: opts.cwd, inherit: true });
673
+ log.ok(`${id} uninstalled from Claude Code`);
674
+ emit(`uninstalled ${id} from Claude Code`);
675
+ return;
676
+ }
677
+ // Codex path.
678
+ const home = codexHome();
679
+ const configPath = path.join(home, "config.toml");
680
+ if (fs.existsSync(configPath)) {
681
+ const content = fs.readFileSync(configPath, "utf-8");
682
+ // Parse-then-mutate: any parse failure aborts BEFORE we touch the
683
+ // filesystem (cache dir removal also gets skipped) so a damaged
684
+ // config doesn't end up half-uninstalled. The test "config.toml
685
+ // damaged → throw before mutation" locks this in.
686
+ const parsed = parseCodexConfigToml(content, configPath);
687
+ const removed = removeCodexPluginFromConfig(parsed, id);
688
+ if (removed) {
689
+ const next = stringifyToml(parsed);
690
+ atomicWriteFile(configPath, next.endsWith("\n") ? next : `${next}\n`);
691
+ log.ok(`${id} disabled in Codex config.toml`);
692
+ emit(`removed ${id} from Codex config.toml`);
693
+ }
694
+ else {
695
+ log.skip(`${id} not present in Codex config.toml`);
696
+ emit(`${id} not present in Codex config.toml`);
697
+ }
698
+ }
699
+ else {
700
+ log.skip(`Codex config.toml not present`);
701
+ emit(`Codex config.toml not present`);
702
+ }
703
+ // Cache dir: ~/.codex/plugins/cache/<marketplace>/<plugin>/
704
+ // PLUGIN_ID_RE constrains both segments to a safe charset, so the
705
+ // path can't escape via injection. rmSync with recursive+force is
706
+ // the standard rm-rf idiom; missing dir is a no-op.
707
+ const cacheDir = path.join(home, "plugins", "cache", marketplace, plugin);
708
+ if (fs.existsSync(cacheDir)) {
709
+ fs.rmSync(cacheDir, { recursive: true, force: true });
710
+ log.ok(`${id} cache directory removed`);
711
+ emit(`removed Codex cache directory for ${id}`);
712
+ }
713
+ else {
714
+ log.skip(`${id} cache directory not present`);
715
+ emit(`Codex cache directory for ${id} not present`);
716
+ }
717
+ }
@@ -0,0 +1,2 @@
1
+ import type { Catalog as ScanCatalog } from "./state.js";
2
+ export declare function buildScanCatalog(packageRoot: string): Promise<ScanCatalog>;