auriga-cli 1.15.1 → 1.16.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/skills.d.ts CHANGED
@@ -8,3 +8,32 @@ export declare function planSkillInstallCommands(selected: string[], lock: Skill
8
8
  }[];
9
9
  export declare function installSkills(packageRoot: string, opts: InstallOpts): Promise<void>;
10
10
  export declare function installRecommendedSkills(packageRoot: string, opts: InstallOpts): Promise<void>;
11
+ /**
12
+ * Uninstall a single skill by name. Strategy:
13
+ * 1. Try `npx -y skills remove <name>` (the canonical path; future-proof
14
+ * against upstream-internal cleanup we don't know about).
15
+ * 2. If the CLI doesn't support `remove`, fall back to manual cleanup:
16
+ * - rm `<cwd>/.claude/skills/<name>` (Claude Code view)
17
+ * - rm `<cwd>/.agents/skills/<name>` (Codex / other agents view)
18
+ * - remove the entry from `<cwd>/skills-lock.json` if present
19
+ *
20
+ * Idempotent: missing files / unknown skill names are no-ops, NOT errors.
21
+ * A repeated uninstall must succeed silently so the SSE caller can replay
22
+ * the request without special-casing.
23
+ */
24
+ export declare function uninstallSkill(name: string, opts: {
25
+ cwd: string;
26
+ scope?: "project" | "user";
27
+ onLog?: (line: string) => void;
28
+ }): Promise<void>;
29
+ /**
30
+ * Manual fallback used when `npx skills remove` is unavailable. Exported
31
+ * for test coverage (the exec-success path doesn't exercise it).
32
+ *
33
+ * Steps (each idempotent):
34
+ * - rm-rf `<cwd>/.claude/skills/<name>` if present
35
+ * - rm-rf `<cwd>/.agents/skills/<name>` if present
36
+ * - update `<cwd>/skills-lock.json` (drop the `.skills[name]` key,
37
+ * atomic write) if present and the key exists
38
+ */
39
+ export declare function uninstallSkillManual(name: string, cwd: string, onLog?: (line: string) => void, scope?: "project" | "user"): Promise<void>;
package/dist/skills.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
2
3
  import path from "node:path";
3
4
  import { checkbox, select } from "@inquirer/prompts";
4
- import { exec, log, withEsc } from "./utils.js";
5
+ import { atomicWriteFile, exec, execAsync, log, withEsc } from "./utils.js";
5
6
  // Curated default-on set: skills that the workflow in the root CLAUDE.md
6
7
  // directly references. Anything else in skills-lock.json is surfaced via
7
8
  // installRecommendedSkills as an opt-in utility.
@@ -105,7 +106,16 @@ async function installSelected(entries, defaultChecked, opts) {
105
106
  for (const batch of batches) {
106
107
  console.log(`\nInstalling ${batch.skills.join(", ")} from ${batch.source}...`);
107
108
  try {
108
- exec(batch.command, { inherit: true });
109
+ if (opts.onLog) {
110
+ // Web UI / non-TTY path — stream stdout/stderr through the per-line
111
+ // callback so SSE subscribers see install progress in real time
112
+ // (spec §6.4).
113
+ opts.onLog(`▸ ${batch.command}`, "stdout");
114
+ await execAsync(batch.command, { onLine: opts.onLog });
115
+ }
116
+ else {
117
+ exec(batch.command, { inherit: true });
118
+ }
109
119
  for (const name of batch.skills)
110
120
  log.ok(`${name}: installed`);
111
121
  }
@@ -141,3 +151,136 @@ export async function installRecommendedSkills(packageRoot, opts) {
141
151
  const entries = Object.entries(lock.skills).filter(([name]) => !WORKFLOW_SKILLS.includes(name));
142
152
  await installSelected(entries, false, opts);
143
153
  }
154
+ // --- Uninstall ----------------------------------------------------------------
155
+ /**
156
+ * Detect "unknown subcommand" style failures from the upstream
157
+ * `npx skills` CLI so we can fall back to the manual cleanup path.
158
+ *
159
+ * Substring match instead of a strict regex: the upstream tool's error
160
+ * wording varies across versions (`Unknown command remove`,
161
+ * `unrecognized command 'remove'`, `error: invalid argument 'remove'`).
162
+ * We keep the filter broad so a CLI rename doesn't lock the fallback
163
+ * shut on the next minor release; safer to fall back on a recognized
164
+ * not-supported signal than to mask a different failure mode.
165
+ *
166
+ * If the upstream CLI ever stops emitting the substring `remove` in its
167
+ * "unsupported subcommand" path, this check will incorrectly propagate
168
+ * the error instead of falling back — at that point the user gets a
169
+ * loud failure (good), and we tighten the matcher.
170
+ */
171
+ function isSkillsRemoveUnsupported(err) {
172
+ const text = err instanceof Error
173
+ ? `${err.message}\n${err.stderr ?? ""}`
174
+ : String(err);
175
+ return /unknown command|unrecognized command|invalid argument|unknown option|unsupported/i.test(text)
176
+ && /remove/i.test(text);
177
+ }
178
+ const SKILL_NAME_RE_STRICT = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
179
+ /**
180
+ * Uninstall a single skill by name. Strategy:
181
+ * 1. Try `npx -y skills remove <name>` (the canonical path; future-proof
182
+ * against upstream-internal cleanup we don't know about).
183
+ * 2. If the CLI doesn't support `remove`, fall back to manual cleanup:
184
+ * - rm `<cwd>/.claude/skills/<name>` (Claude Code view)
185
+ * - rm `<cwd>/.agents/skills/<name>` (Codex / other agents view)
186
+ * - remove the entry from `<cwd>/skills-lock.json` if present
187
+ *
188
+ * Idempotent: missing files / unknown skill names are no-ops, NOT errors.
189
+ * A repeated uninstall must succeed silently so the SSE caller can replay
190
+ * the request without special-casing.
191
+ */
192
+ export async function uninstallSkill(name, opts) {
193
+ // Defensive: the CLI parser pre-validates skill names against the
194
+ // catalog before dispatch, but the server route doesn't, and we
195
+ // interpolate `name` into a shell command + filesystem path. Reject
196
+ // anything that didn't pass the install-side regex so a malformed
197
+ // input can't escape via either vector.
198
+ if (!SKILL_NAME_RE_STRICT.test(name)) {
199
+ throw new Error(`uninstallSkill: invalid skill name ${JSON.stringify(name)}`);
200
+ }
201
+ const cwd = path.resolve(opts.cwd);
202
+ const scope = opts.scope ?? "project";
203
+ // Install side maps outer `user` → internal `-g` (global). Mirror that
204
+ // on remove so user-scope skills aren't silently no-op'd.
205
+ const globalFlag = scope === "user" ? " -g" : "";
206
+ const emit = (line) => {
207
+ opts.onLog?.(line);
208
+ };
209
+ try {
210
+ exec(`npx -y skills remove ${name}${globalFlag}`, { cwd });
211
+ log.ok(`${name}: removed via skills CLI`);
212
+ emit(`removed ${name} via skills CLI`);
213
+ return;
214
+ }
215
+ catch (err) {
216
+ if (!isSkillsRemoveUnsupported(err)) {
217
+ throw err;
218
+ }
219
+ log.warn(`skills CLI doesn't support 'remove'; falling back to manual cleanup`);
220
+ emit(`skills CLI doesn't support 'remove'; falling back to manual cleanup`);
221
+ }
222
+ await uninstallSkillManual(name, cwd, emit, scope);
223
+ }
224
+ /**
225
+ * Manual fallback used when `npx skills remove` is unavailable. Exported
226
+ * for test coverage (the exec-success path doesn't exercise it).
227
+ *
228
+ * Steps (each idempotent):
229
+ * - rm-rf `<cwd>/.claude/skills/<name>` if present
230
+ * - rm-rf `<cwd>/.agents/skills/<name>` if present
231
+ * - update `<cwd>/skills-lock.json` (drop the `.skills[name]` key,
232
+ * atomic write) if present and the key exists
233
+ */
234
+ export async function uninstallSkillManual(name, cwd, onLog, scope = "project") {
235
+ if (!SKILL_NAME_RE_STRICT.test(name)) {
236
+ throw new Error(`uninstallSkillManual: invalid skill name ${JSON.stringify(name)}`);
237
+ }
238
+ const emit = (line) => { onLog?.(line); };
239
+ // User scope cleans `~/.claude/skills/<name>` + `~/.agents/skills/<name>`.
240
+ // Project scope cleans `<cwd>/.claude/...` + `<cwd>/.agents/...`.
241
+ // The lockfile is per-project; user-scope uninstall never mutates it.
242
+ const baseDir = scope === "user" ? os.homedir() : cwd;
243
+ const claudeDir = path.join(baseDir, ".claude", "skills", name);
244
+ const agentsDir = path.join(baseDir, ".agents", "skills", name);
245
+ for (const [label, dir] of [
246
+ [".claude/skills", claudeDir],
247
+ [".agents/skills", agentsDir],
248
+ ]) {
249
+ if (fs.existsSync(dir) || fs.lstatSync(dir, { throwIfNoEntry: false })) {
250
+ fs.rmSync(dir, { recursive: true, force: true });
251
+ log.ok(`${label}/${name} removed`);
252
+ emit(`removed ${label}/${name}`);
253
+ }
254
+ else {
255
+ log.skip(`${label}/${name} not present`);
256
+ }
257
+ }
258
+ if (scope === "user") {
259
+ // No `~/skills-lock.json` to mutate.
260
+ return;
261
+ }
262
+ const lockPath = path.join(cwd, "skills-lock.json");
263
+ if (!fs.existsSync(lockPath)) {
264
+ emit(`skills-lock.json not present`);
265
+ return;
266
+ }
267
+ // Read + validate so we don't silently corrupt a hand-edited lockfile.
268
+ // If the user damaged it before calling us, throw — the install side's
269
+ // contract is "lockfile is authoritative", and writing back a partial
270
+ // tree would amplify their damage.
271
+ const raw = JSON.parse(fs.readFileSync(lockPath, "utf-8"));
272
+ validateSkillsLock(raw);
273
+ if (!(name in raw.skills)) {
274
+ emit(`${name} not in skills-lock.json`);
275
+ return;
276
+ }
277
+ // Build a shallow copy so we don't mutate `raw` in case the caller
278
+ // holds a reference. Object spread preserves insertion order of
279
+ // remaining keys (deterministic test output).
280
+ const nextSkills = { ...raw.skills };
281
+ delete nextSkills[name];
282
+ const next = { ...raw, skills: nextSkills };
283
+ atomicWriteFile(lockPath, JSON.stringify(next, null, 2) + "\n");
284
+ log.ok(`${name} removed from skills-lock.json`);
285
+ emit(`removed ${name} from skills-lock.json`);
286
+ }
@@ -0,0 +1,63 @@
1
+ import type { PluginState, StateReport } from "./api-types.js";
2
+ export interface Catalog {
3
+ workflowVersion: string;
4
+ skills: Record<string, {
5
+ description: string;
6
+ expectedHash: string;
7
+ isWorkflow: boolean;
8
+ }>;
9
+ recommendedSkills: Record<string, {
10
+ description: string;
11
+ expectedHash: string;
12
+ }>;
13
+ plugins: Record<string, {
14
+ description: string;
15
+ /** Agents this plugin can install into. Length 1 or 2. */
16
+ agents: ("claude" | "codex")[];
17
+ expectedVersion?: string;
18
+ }>;
19
+ hooks: Record<string, {
20
+ description: string;
21
+ expectedHash: string;
22
+ }>;
23
+ }
24
+ export interface ScanOptions {
25
+ execPluginList?: () => Promise<{
26
+ installed: any[];
27
+ available: any[];
28
+ }>;
29
+ readCodexConfig?: () => Promise<string | null>;
30
+ readCodexPluginsDir?: () => Promise<Map<string, string>>;
31
+ }
32
+ export declare function scanState(projectRoot: string, catalog: Catalog, opts?: ScanOptions): Promise<StateReport>;
33
+ /**
34
+ * Dedupe plugins by `id`, merging dual-Agent records into a single
35
+ * multi-agent row. Aggregation rules:
36
+ *
37
+ * agents: union of all agent arrays for this id (claude before codex).
38
+ * status: installed ⇔ every agent's record is installed
39
+ * not-installed ⇔ every agent's record is not-installed
40
+ * otherwise → update-available (partial install or any agent
41
+ * with a pending update). One Apply covers all
42
+ * gaps because the handler iterates `agents`.
43
+ *
44
+ * Non-status fields (description, currentVersion, expectedVersion,
45
+ * versionSource) come from the first record we see. Today both sides report
46
+ * the same description (catalog-driven) and the same versions for any
47
+ * registry-pinned plugin, so this is safe; if a future divergence appears
48
+ * we'll need a deliberate merge policy.
49
+ */
50
+ export declare function mergePluginsById(records: PluginState[]): PluginState[];
51
+ /** Default: run `claude plugins list --json` and `claude plugins list
52
+ * --available --json`. Returns null is NOT an option here — server.ts
53
+ * decides whether to pass this function based on `which claude`. */
54
+ export declare function defaultExecPluginList(): Promise<{
55
+ installed: any[];
56
+ available: any[];
57
+ }>;
58
+ export declare function defaultReadCodexConfig(): Promise<string | null>;
59
+ /** Walk `~/.codex/plugins/cache/<marketplace>/<plugin>/<version>/`, returning
60
+ * the highest-mtime version per `<plugin>@<marketplace>` id. The version
61
+ * semantics are catalog-pinned, so we surface the directory name verbatim
62
+ * rather than try to semver-sort. */
63
+ export declare function defaultReadCodexPluginsDir(): Promise<Map<string, string>>;