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/README.md +14 -2
- package/README.zh-CN.md +14 -2
- package/dist/api-types.d.ts +115 -0
- package/dist/api-types.js +4 -0
- package/dist/apply-handlers.d.ts +17 -0
- package/dist/apply-handlers.js +186 -0
- package/dist/catalog.json +3 -3
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +220 -0
- package/dist/help.js +2 -0
- package/dist/hooks.d.ts +30 -0
- package/dist/hooks.js +89 -0
- package/dist/plugins.d.ts +29 -0
- package/dist/plugins.js +137 -6
- package/dist/scan-catalog.d.ts +2 -0
- package/dist/scan-catalog.js +138 -0
- package/dist/server.d.ts +71 -0
- package/dist/server.js +759 -0
- package/dist/skills.d.ts +29 -0
- package/dist/skills.js +146 -3
- package/dist/state.d.ts +63 -0
- package/dist/state.js +623 -0
- package/dist/ui-fetch.d.ts +29 -0
- package/dist/ui-fetch.js +267 -0
- package/dist/utils.d.ts +22 -0
- package/dist/utils.js +58 -1
- package/dist/workflow.d.ts +22 -0
- package/dist/workflow.js +63 -0
- package/package.json +5 -3
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
561
|
-
|
|
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
|
+
}
|