skillswitch 0.1.1 → 0.1.3

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.
Files changed (2) hide show
  1. package/dist/cli.js +494 -106
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // src/cli.ts
4
4
  import { Command } from "commander";
5
5
  import * as readline from "readline";
6
+ import * as fs6 from "fs";
6
7
 
7
8
  // src/scanner.ts
8
9
  import * as fs from "fs";
@@ -36,8 +37,13 @@ function scanStandaloneSkills(claudeDir = defaultClaudeDir) {
36
37
  const filePath = path.join(dir, file);
37
38
  if (!fs.statSync(filePath).isFile()) continue;
38
39
  const name = file.slice(0, -3);
39
- const content = fs.readFileSync(filePath, "utf-8");
40
- skills.push({ source: "standalone", name, path: filePath, status, description: extractDescription(content) });
40
+ try {
41
+ const content = fs.readFileSync(filePath, "utf-8");
42
+ skills.push({ source: "standalone", name, path: filePath, status, description: extractDescription(content) });
43
+ } catch {
44
+ process.stderr.write(`Warning: could not read ${filePath} \u2014 skipping
45
+ `);
46
+ }
41
47
  }
42
48
  }
43
49
  readDir(skillsDir2, "active");
@@ -48,13 +54,30 @@ function scanPlugins(claudeDir = defaultClaudeDir) {
48
54
  const pluginsDir = path.join(claudeDir, "plugins");
49
55
  const installedFile = path.join(pluginsDir, "installed_plugins.json");
50
56
  if (!fs.existsSync(installedFile)) return [];
51
- const raw = JSON.parse(fs.readFileSync(installedFile, "utf-8"));
52
- const pluginMap = raw.plugins ?? {};
57
+ let pluginMap;
58
+ try {
59
+ const raw = JSON.parse(fs.readFileSync(installedFile, "utf-8"));
60
+ pluginMap = raw.plugins ?? {};
61
+ } catch (err) {
62
+ if (err instanceof SyntaxError) {
63
+ process.stderr.write("Warning: plugins/installed_plugins.json is malformed \u2014 skipping plugin scan\n");
64
+ return [];
65
+ }
66
+ throw err;
67
+ }
53
68
  const blockedSet = /* @__PURE__ */ new Set();
54
69
  const blocklistFile = path.join(pluginsDir, "blocklist.json");
55
70
  if (fs.existsSync(blocklistFile)) {
56
- const bl = JSON.parse(fs.readFileSync(blocklistFile, "utf-8"));
57
- for (const entry of bl.plugins ?? []) blockedSet.add(entry.plugin);
71
+ try {
72
+ const bl = JSON.parse(fs.readFileSync(blocklistFile, "utf-8"));
73
+ for (const entry of bl.plugins ?? []) blockedSet.add(entry.plugin);
74
+ } catch (err) {
75
+ if (err instanceof SyntaxError) {
76
+ process.stderr.write("Warning: plugins/blocklist.json is malformed \u2014 treating as empty\n");
77
+ } else {
78
+ throw err;
79
+ }
80
+ }
58
81
  }
59
82
  return Object.keys(pluginMap).map((pluginId) => {
60
83
  const atIdx = pluginId.indexOf("@");
@@ -64,7 +87,7 @@ function scanPlugins(claudeDir = defaultClaudeDir) {
64
87
  const cacheBase = path.join(pluginsDir, "cache", sourceMarket, name);
65
88
  const pluginSkills = [];
66
89
  if (fs.existsSync(cacheBase)) {
67
- const versions = fs.readdirSync(cacheBase).filter((d) => fs.statSync(path.join(cacheBase, d)).isDirectory()).sort();
90
+ const versions = fs.readdirSync(cacheBase).filter((d) => fs.statSync(path.join(cacheBase, d)).isDirectory()).sort((a, b) => a.localeCompare(b, void 0, { numeric: true, sensitivity: "base" }));
68
91
  if (versions.length > 0) {
69
92
  const skillsDir2 = path.join(cacheBase, versions[versions.length - 1], "skills");
70
93
  if (fs.existsSync(skillsDir2)) collectPluginSkills(skillsDir2, name, pluginId, status, pluginSkills);
@@ -79,12 +102,23 @@ function collectPluginSkills(dir, pluginName, pluginId, status, out) {
79
102
  if (fs.statSync(entryPath).isDirectory()) {
80
103
  for (const sub of fs.readdirSync(entryPath)) {
81
104
  if (!sub.endsWith(".md")) continue;
82
- const content = fs.readFileSync(path.join(entryPath, sub), "utf-8");
83
- out.push({ source: "plugin", name: `${entry}:${sub.slice(0, -3)}`, plugin: pluginId, status, description: extractDescription(content) });
105
+ const subPath = path.join(entryPath, sub);
106
+ try {
107
+ const content = fs.readFileSync(subPath, "utf-8");
108
+ out.push({ source: "plugin", name: `${entry}:${sub.slice(0, -3)}`, plugin: pluginId, status, description: extractDescription(content) });
109
+ } catch {
110
+ process.stderr.write(`Warning: could not read ${subPath} \u2014 skipping
111
+ `);
112
+ }
84
113
  }
85
114
  } else if (entry.endsWith(".md")) {
86
- const content = fs.readFileSync(entryPath, "utf-8");
87
- out.push({ source: "plugin", name: `${pluginName}:${entry.slice(0, -3)}`, plugin: pluginId, status, description: extractDescription(content) });
115
+ try {
116
+ const content = fs.readFileSync(entryPath, "utf-8");
117
+ out.push({ source: "plugin", name: `${pluginName}:${entry.slice(0, -3)}`, plugin: pluginId, status, description: extractDescription(content) });
118
+ } catch {
119
+ process.stderr.write(`Warning: could not read ${entryPath} \u2014 skipping
120
+ `);
121
+ }
88
122
  }
89
123
  }
90
124
  }
@@ -107,6 +141,7 @@ function disableSkill(name, claudeDir = defaultClaudeDir2) {
107
141
  function enableSkill(name, claudeDir = defaultClaudeDir2) {
108
142
  const src = path2.join(disabledDir(claudeDir), `${name}.md`);
109
143
  if (!fs2.existsSync(src)) throw new Error(`Skill "${name}" is not in disabled directory`);
144
+ fs2.mkdirSync(skillsDir(claudeDir), { recursive: true });
110
145
  fs2.renameSync(src, path2.join(skillsDir(claudeDir), `${name}.md`));
111
146
  }
112
147
 
@@ -129,6 +164,10 @@ async function readBlocklist(claudeDir = defaultClaudeDir3) {
129
164
  if (err.code === "ENOENT") {
130
165
  return { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), plugins: [] };
131
166
  }
167
+ if (err instanceof SyntaxError) {
168
+ process.stderr.write("Warning: blocklist.json is malformed \u2014 treating as empty\n");
169
+ return { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), plugins: [] };
170
+ }
132
171
  throw err;
133
172
  }
134
173
  }
@@ -151,10 +190,11 @@ async function blockPlugin(pluginId, reason, claudeDir = defaultClaudeDir3) {
151
190
  async function unblockPlugin(pluginId, claudeDir = defaultClaudeDir3) {
152
191
  const blocklist = await readBlocklist(claudeDir);
153
192
  const filtered = blocklist.plugins.filter((e) => e.plugin !== pluginId);
154
- if (filtered.length === blocklist.plugins.length) return;
193
+ if (filtered.length === blocklist.plugins.length) return false;
155
194
  blocklist.plugins = filtered;
156
195
  blocklist.fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
157
196
  await writeBlocklist(blocklist, claudeDir);
197
+ return true;
158
198
  }
159
199
  async function setBlockedPlugins(pluginIds, reason, claudeDir = defaultClaudeDir3) {
160
200
  const now = (/* @__PURE__ */ new Date()).toISOString();
@@ -178,7 +218,11 @@ function readProfileStore(claudeDir = defaultClaudeDir4) {
178
218
  return JSON.parse(raw);
179
219
  } catch (err) {
180
220
  if (err.code === "ENOENT") {
181
- return { active: null, profiles: {} };
221
+ return { active: null, previous: null, profiles: {} };
222
+ }
223
+ if (err instanceof SyntaxError) {
224
+ process.stderr.write("Warning: profiles.json is corrupt \u2014 resetting to empty store\n");
225
+ return { active: null, previous: null, profiles: {} };
182
226
  }
183
227
  throw err;
184
228
  }
@@ -194,6 +238,7 @@ function saveProfile(name, skills, plugins, claudeDir = defaultClaudeDir4) {
194
238
  const store = readProfileStore(claudeDir);
195
239
  const profile = {
196
240
  created: store.profiles[name]?.created ?? (/* @__PURE__ */ new Date()).toISOString(),
241
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
197
242
  skills,
198
243
  plugins
199
244
  };
@@ -207,12 +252,113 @@ function deleteProfile(name, claudeDir = defaultClaudeDir4) {
207
252
  delete store.profiles[name];
208
253
  writeProfileStore(store, claudeDir);
209
254
  }
210
- async function activateProfile(name, claudeDir = defaultClaudeDir4) {
255
+ function renameProfile(oldName, newName, claudeDir = defaultClaudeDir4) {
256
+ const store = readProfileStore(claudeDir);
257
+ if (!store.profiles[oldName]) throw new Error(`Profile "${oldName}" does not exist`);
258
+ if (store.profiles[newName]) throw new Error(`Profile "${newName}" already exists`);
259
+ store.profiles[newName] = store.profiles[oldName];
260
+ delete store.profiles[oldName];
261
+ if (store.active === oldName) store.active = newName;
262
+ if (store.previous === oldName) store.previous = newName;
263
+ writeProfileStore(store, claudeDir);
264
+ }
265
+ function copyProfile(srcName, dstName, claudeDir = defaultClaudeDir4) {
266
+ const store = readProfileStore(claudeDir);
267
+ if (!store.profiles[srcName]) throw new Error(`Profile "${srcName}" does not exist`);
268
+ if (store.profiles[dstName]) throw new Error(`Profile "${dstName}" already exists`);
269
+ store.profiles[dstName] = {
270
+ ...store.profiles[srcName],
271
+ created: (/* @__PURE__ */ new Date()).toISOString(),
272
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
273
+ };
274
+ writeProfileStore(store, claudeDir);
275
+ }
276
+ function diffProfile(name, claudeDir = defaultClaudeDir4) {
277
+ const store = readProfileStore(claudeDir);
278
+ const profile = store.profiles[name];
279
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
280
+ const profileSkills = new Set(profile.skills);
281
+ const skillsDir2 = path4.join(claudeDir, "skills");
282
+ const disabledDir2 = path4.join(claudeDir, "skills", ".disabled");
283
+ const toDisable = [];
284
+ const toEnable = [];
285
+ if (fs4.existsSync(skillsDir2)) {
286
+ for (const file of fs4.readdirSync(skillsDir2)) {
287
+ if (!file.endsWith(".md") || !fs4.statSync(path4.join(skillsDir2, file)).isFile()) continue;
288
+ const n = file.slice(0, -3);
289
+ if (!profileSkills.has(n)) toDisable.push(n);
290
+ }
291
+ }
292
+ if (fs4.existsSync(disabledDir2)) {
293
+ for (const file of fs4.readdirSync(disabledDir2)) {
294
+ if (!file.endsWith(".md")) continue;
295
+ const n = file.slice(0, -3);
296
+ if (profileSkills.has(n)) toEnable.push(n);
297
+ }
298
+ }
299
+ const profilePlugins = new Set(profile.plugins);
300
+ const toBlock = [];
301
+ const toUnblock = [];
302
+ try {
303
+ const raw = JSON.parse(fs4.readFileSync(path4.join(claudeDir, "plugins", "installed_plugins.json"), "utf-8"));
304
+ const installed = Object.keys(raw?.plugins ?? {});
305
+ let currentlyBlocked = /* @__PURE__ */ new Set();
306
+ try {
307
+ const blRaw = JSON.parse(fs4.readFileSync(path4.join(claudeDir, "plugins", "blocklist.json"), "utf-8"));
308
+ currentlyBlocked = new Set(blRaw.plugins.map((e) => e.plugin));
309
+ } catch {
310
+ }
311
+ for (const id of installed) {
312
+ if (!profilePlugins.has(id) && !currentlyBlocked.has(id)) toBlock.push(id);
313
+ if (profilePlugins.has(id) && currentlyBlocked.has(id)) toUnblock.push(id);
314
+ }
315
+ } catch {
316
+ }
317
+ return { toEnable, toDisable, toBlock, toUnblock };
318
+ }
319
+ function exportProfile(name, claudeDir = defaultClaudeDir4) {
320
+ const store = readProfileStore(claudeDir);
321
+ const profile = store.profiles[name];
322
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
323
+ return JSON.stringify({ name, ...profile }, null, 2);
324
+ }
325
+ function importProfile(jsonStr, claudeDir = defaultClaudeDir4) {
326
+ let parsed;
327
+ try {
328
+ parsed = JSON.parse(jsonStr);
329
+ } catch {
330
+ throw new Error("Invalid JSON in import file");
331
+ }
332
+ if (!parsed.name || !Array.isArray(parsed.skills) || !Array.isArray(parsed.plugins)) {
333
+ throw new Error('Import file must have "name", "skills", and "plugins" fields');
334
+ }
335
+ saveProfile(parsed.name, parsed.skills, parsed.plugins, claudeDir);
336
+ return parsed.name;
337
+ }
338
+ function validateProfile(name, claudeDir = defaultClaudeDir4) {
211
339
  const store = readProfileStore(claudeDir);
212
340
  const profile = store.profiles[name];
213
- if (!profile) {
214
- throw new Error(`Profile "${name}" does not exist`);
341
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
342
+ const skillsDir2 = path4.join(claudeDir, "skills");
343
+ const disabledDir2 = path4.join(claudeDir, "skills", ".disabled");
344
+ const onDisk = /* @__PURE__ */ new Set();
345
+ for (const dir of [skillsDir2, disabledDir2]) {
346
+ if (!fs4.existsSync(dir)) continue;
347
+ for (const file of fs4.readdirSync(dir)) {
348
+ if (file.endsWith(".md")) onDisk.add(file.slice(0, -3));
349
+ }
350
+ }
351
+ const validSkills = [];
352
+ const ghostSkills = [];
353
+ for (const skill of profile.skills) {
354
+ (onDisk.has(skill) ? validSkills : ghostSkills).push(skill);
215
355
  }
356
+ return { validSkills, ghostSkills };
357
+ }
358
+ async function activateProfile(name, claudeDir = defaultClaudeDir4) {
359
+ const store = readProfileStore(claudeDir);
360
+ const profile = store.profiles[name];
361
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
216
362
  const profileSkills = new Set(profile.skills);
217
363
  const skillsDir2 = path4.join(claudeDir, "skills");
218
364
  const disabledDir2 = path4.join(claudeDir, "skills", ".disabled");
@@ -251,6 +397,7 @@ async function activateProfile(name, claudeDir = defaultClaudeDir4) {
251
397
  if (err.code !== "ENOENT") throw err;
252
398
  }
253
399
  await setBlockedPlugins(pluginsBlocked, `blocked by profile: ${name}`, claudeDir);
400
+ store.previous = store.active;
254
401
  store.active = name;
255
402
  writeProfileStore(store, claudeDir);
256
403
  return { enabled, disabled, pluginsBlocked };
@@ -295,8 +442,21 @@ function generateCatalog(claudeDir = defaultClaudeDir5) {
295
442
  }
296
443
 
297
444
  // src/cli.ts
445
+ process.on("unhandledRejection", (err) => {
446
+ process.stderr.write(`Error: ${err.message ?? err}
447
+ `);
448
+ process.exit(1);
449
+ });
450
+ function getClaudeDir(opts) {
451
+ return opts["claudeDir"] ?? process.env["SKILLSWITCH_CLAUDE_DIR"] ?? defaultClaudeDir;
452
+ }
453
+ function validateProfileName(name) {
454
+ if (!name || name.trim() === "") throw new Error("Profile name cannot be empty");
455
+ if (name.length > 64) throw new Error("Profile name must be 64 characters or less");
456
+ if (/[/\\]/.test(name)) throw new Error('Profile name cannot contain "/" or "\\"');
457
+ }
298
458
  var program = new Command();
299
- program.name("skillswitch").description("Manage Claude Code skills").version("0.1.0");
459
+ program.name("skillswitch").description("Manage Claude Code skills").version("0.1.2").option("--claude-dir <path>", "Override the Claude config directory (default: ~/.claude)");
300
460
  function confirm(prompt) {
301
461
  return new Promise((resolve) => {
302
462
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -306,25 +466,45 @@ function confirm(prompt) {
306
466
  });
307
467
  });
308
468
  }
309
- program.command("list").description("Show all skills grouped by source").option("--disabled", "Show only disabled skills").action((opts) => {
310
- const standalone = scanStandaloneSkills();
311
- const plugins = scanPlugins();
312
- const ss = opts.disabled ? standalone.filter((s) => s.status === "disabled") : standalone;
469
+ program.command("list").description("Show all skills grouped by source").option("--disabled", "Show only disabled skills").option("--enabled", "Show only enabled (active) skills").option("--json", "Output as JSON").action((opts) => {
470
+ const claudeDir = getClaudeDir(program.opts());
471
+ const standalone = scanStandaloneSkills(claudeDir);
472
+ const plugins = scanPlugins(claudeDir);
473
+ if (opts.json) {
474
+ const filter = opts.disabled ? "disabled" : opts.enabled ? "active" : null;
475
+ const ss2 = filter ? standalone.filter((s) => s.status === filter) : standalone;
476
+ const pp2 = filter ? plugins.filter((p) => p.status === (filter === "active" ? "active" : "disabled")) : plugins;
477
+ process.stdout.write(JSON.stringify({ standalone: ss2, plugins: pp2 }, null, 2) + "\n");
478
+ return;
479
+ }
480
+ const statusFilter = opts.disabled ? "disabled" : opts.enabled ? "active" : null;
481
+ const ss = statusFilter ? standalone.filter((s) => s.status === statusFilter) : standalone;
313
482
  console.log(`
314
483
  Standalone (${standalone.length} total):`);
315
484
  for (const s of ss) console.log(` ${s.name}${s.status === "disabled" ? " [disabled]" : ""}`);
316
- const pp = opts.disabled ? plugins.filter((p) => p.status === "disabled") : plugins;
317
- for (const p of pp) console.log(`
485
+ const pp = statusFilter ? plugins.filter((p) => p.status === statusFilter) : plugins;
486
+ for (const p of pp) {
487
+ console.log(`
318
488
  ${p.id}${p.status === "disabled" ? " [DISABLED]" : ""} (${p.skills.length} skills)`);
489
+ for (const s of p.skills) console.log(` ${s.name}${s.status === "disabled" ? " [disabled]" : ""}`);
490
+ }
319
491
  });
320
- program.command("search <query>").description("Search skills by name or description (substring match)").action((query) => {
492
+ program.command("search <query>").description("Search skills by name or description (substring match)").option("--status <status>", "Filter by status: active or disabled").option("--json", "Output as JSON").action((query, opts) => {
493
+ const claudeDir = getClaudeDir(program.opts());
321
494
  const q = query.toLowerCase();
322
- const standalone = scanStandaloneSkills();
323
- const plugins = scanPlugins();
324
- const matches = [
495
+ const standalone = scanStandaloneSkills(claudeDir);
496
+ const plugins = scanPlugins(claudeDir);
497
+ let matches = [
325
498
  ...standalone.filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q)),
326
499
  ...plugins.flatMap((p) => p.skills).filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q))
327
500
  ];
501
+ if (opts.status) {
502
+ matches = matches.filter((s) => s.status === opts.status);
503
+ }
504
+ if (opts.json) {
505
+ process.stdout.write(JSON.stringify(matches, null, 2) + "\n");
506
+ return;
507
+ }
328
508
  if (!matches.length) {
329
509
  console.log(`No skills matching "${query}".`);
330
510
  return;
@@ -337,101 +517,190 @@ ${matches.length} match(es) for "${query}":
337
517
  if (s.description) console.log(` ${s.description}`);
338
518
  }
339
519
  });
340
- program.command("status").description("Show active profile and skill counts").action(() => {
341
- const store = readProfileStore();
342
- const standalone = scanStandaloneSkills();
343
- const plugins = scanPlugins();
520
+ program.command("status").description("Show active profile and skill counts").option("--json", "Output as JSON").action((opts) => {
521
+ const claudeDir = getClaudeDir(program.opts());
522
+ const store = readProfileStore(claudeDir);
523
+ const standalone = scanStandaloneSkills(claudeDir);
524
+ const plugins = scanPlugins(claudeDir);
344
525
  const active = standalone.filter((s) => s.status === "active").length + plugins.filter((p) => p.status === "active").reduce((n, p) => n + p.skills.length, 0);
345
526
  const disabled = standalone.filter((s) => s.status === "disabled").length + plugins.filter((p) => p.status === "disabled").reduce((n, p) => n + p.skills.length, 0);
527
+ if (opts.json) {
528
+ process.stdout.write(JSON.stringify({ activeProfile: store.active, active, disabled, total: active + disabled }, null, 2) + "\n");
529
+ return;
530
+ }
346
531
  console.log(`Active profile : ${store.active ?? "none"}`);
347
532
  console.log(`Enabled skills : ${active}`);
348
533
  console.log(`Disabled skills: ${disabled}`);
349
534
  console.log(`Total : ${active + disabled}`);
350
535
  });
351
- program.command("disable <name>").description("Disable a skill (substring match) or a plugin (--plugin)").option("--plugin", "Treat <name> as a full plugin ID (name@source)").option("--dry-run", "Preview without making changes").action(async (name, opts) => {
352
- if (opts.plugin) {
353
- if (opts.dryRun) {
354
- console.log(`[dry-run] Would block plugin: ${name}`);
536
+ program.command("disable <name>").description("Disable a skill (substring match) or a plugin (--plugin)").option("--plugin", "Treat <name> as a full plugin ID (name@source)").option("--exact", "Require exact name match instead of substring match").option("--dry-run", "Preview without making changes").option("--quiet", "Suppress output except errors").action(async (name, opts) => {
537
+ const claudeDir = getClaudeDir(program.opts());
538
+ const log = (msg) => {
539
+ if (!opts.quiet) console.log(msg);
540
+ };
541
+ try {
542
+ if (opts.plugin) {
543
+ const plugins = scanPlugins(claudeDir);
544
+ const matched = plugins.filter(
545
+ (p) => opts.exact ? p.id === name : p.id.includes(name)
546
+ );
547
+ if (!matched.length) {
548
+ console.log(`No plugins matching "${name}".`);
549
+ return;
550
+ }
551
+ if (matched.length > 1 && !opts.exact) {
552
+ console.log(`Matches: ${matched.map((p) => p.id).join(", ")}`);
553
+ if (!await confirm(`Disable all ${matched.length} plugins? (y/N) `)) {
554
+ console.log("Aborted.");
555
+ return;
556
+ }
557
+ }
558
+ for (const p of matched) {
559
+ if (opts.dryRun) log(`[dry-run] Would block plugin: ${p.id}`);
560
+ else {
561
+ await blockPlugin(p.id, "skillswitch: manually disabled", claudeDir);
562
+ log(`Plugin blocked: ${p.id}`);
563
+ }
564
+ }
355
565
  return;
356
566
  }
357
- await blockPlugin(name, "skillswitch: manually disabled");
358
- console.log(`Plugin blocked: ${name}`);
359
- return;
360
- }
361
- const matches = scanStandaloneSkills().filter((s) => s.name.includes(name) && s.status === "active");
362
- if (!matches.length) {
363
- console.log(`No active skills matching "${name}".`);
364
- return;
365
- }
366
- if (matches.length > 1) {
367
- console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
368
- if (!await confirm(`Disable all ${matches.length}? (y/N) `)) {
369
- console.log("Aborted.");
567
+ const matches = scanStandaloneSkills(claudeDir).filter((s) => {
568
+ const hit = opts.exact ? s.name === name : s.name.includes(name);
569
+ return hit && s.status === "active";
570
+ });
571
+ if (!matches.length) {
572
+ console.log(`No active skills matching "${name}".`);
370
573
  return;
371
574
  }
372
- }
373
- for (const s of matches) {
374
- if (opts.dryRun) console.log(`[dry-run] Would disable: ${s.name}`);
375
- else {
376
- disableSkill(s.name);
377
- console.log(`Disabled: ${s.name}`);
575
+ if (matches.length > 1) {
576
+ console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
577
+ if (!await confirm(`Disable all ${matches.length}? (y/N) `)) {
578
+ console.log("Aborted.");
579
+ return;
580
+ }
378
581
  }
582
+ for (const s of matches) {
583
+ if (opts.dryRun) log(`[dry-run] Would disable: ${s.name}`);
584
+ else {
585
+ disableSkill(s.name, claudeDir);
586
+ log(`Disabled: ${s.name}`);
587
+ }
588
+ }
589
+ } catch (err) {
590
+ console.error(`Error: ${err.message}`);
591
+ process.exit(1);
379
592
  }
380
593
  });
381
- program.command("enable <name>").description("Enable a skill (substring match) or a plugin (--plugin)").option("--plugin", "Treat <name> as a full plugin ID (name@source)").option("--dry-run", "Preview without making changes").action(async (name, opts) => {
382
- if (opts.plugin) {
383
- if (opts.dryRun) {
384
- console.log(`[dry-run] Would unblock plugin: ${name}`);
594
+ program.command("enable <name>").description("Enable a skill (substring match) or a plugin (--plugin)").option("--plugin", "Treat <name> as a full plugin ID (name@source)").option("--exact", "Require exact name match instead of substring match").option("--dry-run", "Preview without making changes").option("--quiet", "Suppress output except errors").action(async (name, opts) => {
595
+ const claudeDir = getClaudeDir(program.opts());
596
+ const log = (msg) => {
597
+ if (!opts.quiet) console.log(msg);
598
+ };
599
+ try {
600
+ if (opts.plugin) {
601
+ const plugins = scanPlugins(claudeDir);
602
+ const matched = plugins.filter(
603
+ (p) => opts.exact ? p.id === name : p.id.includes(name)
604
+ );
605
+ if (!matched.length) {
606
+ console.log(`No plugins matching "${name}".`);
607
+ return;
608
+ }
609
+ for (const p of matched) {
610
+ if (opts.dryRun) {
611
+ log(`[dry-run] Would unblock plugin: ${p.id}`);
612
+ continue;
613
+ }
614
+ const wasBlocked = await unblockPlugin(p.id, claudeDir);
615
+ log(wasBlocked ? `Plugin unblocked: ${p.id}` : `Plugin was not blocked: ${p.id}`);
616
+ }
385
617
  return;
386
618
  }
387
- await unblockPlugin(name);
388
- console.log(`Plugin unblocked: ${name}`);
389
- return;
390
- }
391
- const matches = scanStandaloneSkills().filter((s) => s.name.includes(name) && s.status === "disabled");
392
- if (!matches.length) {
393
- console.log(`No disabled skills matching "${name}".`);
394
- return;
395
- }
396
- if (matches.length > 1) {
397
- console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
398
- if (!await confirm(`Enable all ${matches.length}? (y/N) `)) {
399
- console.log("Aborted.");
619
+ const matches = scanStandaloneSkills(claudeDir).filter((s) => {
620
+ const hit = opts.exact ? s.name === name : s.name.includes(name);
621
+ return hit && s.status === "disabled";
622
+ });
623
+ if (!matches.length) {
624
+ console.log(`No disabled skills matching "${name}".`);
400
625
  return;
401
626
  }
402
- }
403
- for (const s of matches) {
404
- if (opts.dryRun) console.log(`[dry-run] Would enable: ${s.name}`);
405
- else {
406
- enableSkill(s.name);
407
- console.log(`Enabled: ${s.name}`);
627
+ if (matches.length > 1) {
628
+ console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
629
+ if (!await confirm(`Enable all ${matches.length}? (y/N) `)) {
630
+ console.log("Aborted.");
631
+ return;
632
+ }
633
+ }
634
+ for (const s of matches) {
635
+ if (opts.dryRun) log(`[dry-run] Would enable: ${s.name}`);
636
+ else {
637
+ enableSkill(s.name, claudeDir);
638
+ log(`Enabled: ${s.name}`);
639
+ }
408
640
  }
641
+ } catch (err) {
642
+ console.error(`Error: ${err.message}`);
643
+ process.exit(1);
409
644
  }
410
645
  });
411
646
  var profileCmd = program.command("profile").description("Manage skill profiles");
412
647
  profileCmd.command("create <name>").description("Snapshot current enabled skills as a named profile").action((name) => {
413
- const skills = scanStandaloneSkills().filter((s) => s.status === "active").map((s) => s.name);
414
- const plugins = scanPlugins().filter((p) => p.status === "active").map((p) => p.id);
415
- saveProfile(name, skills, plugins);
416
- console.log(`Profile "${name}" saved: ${skills.length} skills, ${plugins.length} plugins.`);
648
+ try {
649
+ const claudeDir = getClaudeDir(program.opts());
650
+ validateProfileName(name);
651
+ const skills = scanStandaloneSkills(claudeDir).filter((s) => s.status === "active").map((s) => s.name);
652
+ const plugins = scanPlugins(claudeDir).filter((p) => p.status === "active").map((p) => p.id);
653
+ const store = readProfileStore(claudeDir);
654
+ if (store.profiles[name]) console.log(`Overwriting existing profile "${name}".`);
655
+ if (!skills.length && !plugins.length) console.warn(`Warning: no active skills or plugins found \u2014 profile may be empty by mistake.`);
656
+ saveProfile(name, skills, plugins, claudeDir);
657
+ console.log(`Profile "${name}" saved: ${skills.length} skills, ${plugins.length} plugins.`);
658
+ } catch (err) {
659
+ console.error(`Error: ${err.message}`);
660
+ process.exit(1);
661
+ }
417
662
  });
418
- profileCmd.command("use <name>").description("Activate a profile").option("--dry-run", "Preview without making changes").action(async (name, opts) => {
419
- if (opts.dryRun) {
420
- const store = readProfileStore();
421
- const p = store.profiles[name];
422
- if (!p) {
423
- console.log(`Profile "${name}" not found.`);
663
+ profileCmd.command("use <name>").description("Activate a profile").option("--dry-run", "Preview without making changes").option("--quiet", "Suppress output except errors").action(async (name, opts) => {
664
+ const claudeDir = getClaudeDir(program.opts());
665
+ try {
666
+ if (opts.dryRun) {
667
+ const store = readProfileStore(claudeDir);
668
+ const p = store.profiles[name];
669
+ if (!p) {
670
+ console.log(`Profile "${name}" not found.`);
671
+ return;
672
+ }
673
+ const diff = diffProfile(name, claudeDir);
674
+ console.log(`[dry-run] Activating "${name}":`);
675
+ if (diff.toEnable.length) console.log(` Enable : ${diff.toEnable.join(", ")}`);
676
+ if (diff.toDisable.length) console.log(` Disable : ${diff.toDisable.join(", ")}`);
677
+ if (diff.toBlock.length) console.log(` Block : ${diff.toBlock.join(", ")}`);
678
+ if (diff.toUnblock.length) console.log(` Unblock : ${diff.toUnblock.join(", ")}`);
679
+ if (!diff.toEnable.length && !diff.toDisable.length && !diff.toBlock.length && !diff.toUnblock.length) {
680
+ console.log(" (no changes)");
681
+ }
424
682
  return;
425
683
  }
426
- console.log(`[dry-run] Would activate "${name}": ${p.skills.length} skills, ${p.plugins.length} plugins.`);
427
- return;
684
+ const result = await activateProfile(name, claudeDir);
685
+ if (!opts.quiet) {
686
+ console.log(`Activated "${name}".`);
687
+ if (result.enabled.length) console.log(` Enabled : ${result.enabled.join(", ")}`);
688
+ if (result.disabled.length) console.log(` Disabled: ${result.disabled.join(", ")}`);
689
+ if (result.pluginsBlocked.length) console.log(` Blocked : ${result.pluginsBlocked.join(", ")}`);
690
+ }
691
+ } catch (err) {
692
+ console.error(`Error: ${err.message}`);
693
+ process.exit(1);
428
694
  }
429
- const result = await activateProfile(name);
430
- console.log(`Activated "${name}": ${result.disabled.length} disabled, ${result.enabled.length} enabled, ${result.pluginsBlocked.length} plugins blocked.`);
431
695
  });
432
- profileCmd.command("list").description("List saved profiles").action(() => {
433
- const store = readProfileStore();
696
+ profileCmd.command("list").description("List saved profiles").option("--json", "Output as JSON").action((opts) => {
697
+ const claudeDir = getClaudeDir(program.opts());
698
+ const store = readProfileStore(claudeDir);
434
699
  const names = Object.keys(store.profiles);
700
+ if (opts.json) {
701
+ process.stdout.write(JSON.stringify({ active: store.active, previous: store.previous, profiles: store.profiles }, null, 2) + "\n");
702
+ return;
703
+ }
435
704
  if (!names.length) {
436
705
  console.log("No profiles saved.");
437
706
  return;
@@ -443,33 +712,148 @@ profileCmd.command("list").description("List saved profiles").action(() => {
443
712
  }
444
713
  });
445
714
  profileCmd.command("show <name>").description("Show skills in a profile").action((name) => {
446
- const store = readProfileStore();
715
+ const claudeDir = getClaudeDir(program.opts());
716
+ const store = readProfileStore(claudeDir);
447
717
  const p = store.profiles[name];
448
718
  if (!p) {
449
719
  console.log(`Profile "${name}" not found.`);
450
720
  return;
451
721
  }
452
- console.log(`Profile "${name}" (${p.created.slice(0, 10)}):`);
722
+ console.log(`Profile "${name}" (created ${p.created.slice(0, 10)}):`);
453
723
  console.log(` Skills (${p.skills.length}): ${p.skills.join(", ") || "none"}`);
454
724
  console.log(` Plugins (${p.plugins.length}): ${p.plugins.join(", ") || "none"}`);
455
725
  });
456
- profileCmd.command("delete <name>").description("Delete a saved profile").action((name) => {
726
+ profileCmd.command("delete <name>").description("Delete a saved profile").option("--force", "Delete even if this is the active profile").action((name, opts) => {
457
727
  try {
458
- deleteProfile(name);
728
+ const claudeDir = getClaudeDir(program.opts());
729
+ if (opts.force) {
730
+ const store = readProfileStore(claudeDir);
731
+ if (store.active === name) {
732
+ store.active = null;
733
+ if (!store.profiles[name]) {
734
+ console.log(`Profile "${name}" not found.`);
735
+ return;
736
+ }
737
+ delete store.profiles[name];
738
+ const storeFile = claudeDir + "/skillctl/profiles.json";
739
+ const { mkdirSync: mkdirSync3, writeFileSync: writeFileSync4, renameSync: renameSync3 } = fs6;
740
+ const dir = storeFile.replace(/\/[^/]+$/, "");
741
+ mkdirSync3(dir, { recursive: true });
742
+ writeFileSync4(storeFile + ".tmp", JSON.stringify(store, null, 2));
743
+ renameSync3(storeFile + ".tmp", storeFile);
744
+ console.log(`Profile "${name}" deleted.`);
745
+ return;
746
+ }
747
+ }
748
+ deleteProfile(name, claudeDir);
459
749
  console.log(`Profile "${name}" deleted.`);
460
- } catch (e) {
461
- console.error(e.message);
750
+ } catch (err) {
751
+ console.error(err.message);
462
752
  process.exit(1);
463
753
  }
464
754
  });
465
- program.command("catalog").description("Generate ~/.claude/SKILLS.md catalog").action(() => {
466
- generateCatalog();
467
- console.log("Catalog written to ~/.claude/SKILLS.md");
468
- console.log("Tip: use @~/.claude/SKILLS.md in any Claude session to reference it.");
755
+ profileCmd.command("rename <old> <new>").description("Rename a profile").action((oldName, newName) => {
756
+ try {
757
+ const claudeDir = getClaudeDir(program.opts());
758
+ validateProfileName(newName);
759
+ renameProfile(oldName, newName, claudeDir);
760
+ console.log(`Profile "${oldName}" renamed to "${newName}".`);
761
+ } catch (err) {
762
+ console.error(`Error: ${err.message}`);
763
+ process.exit(1);
764
+ }
765
+ });
766
+ profileCmd.command("copy <src> <dst>").description("Copy a profile to a new name").action((srcName, dstName) => {
767
+ try {
768
+ const claudeDir = getClaudeDir(program.opts());
769
+ validateProfileName(dstName);
770
+ copyProfile(srcName, dstName, claudeDir);
771
+ console.log(`Profile "${srcName}" copied to "${dstName}".`);
772
+ } catch (err) {
773
+ console.error(`Error: ${err.message}`);
774
+ process.exit(1);
775
+ }
776
+ });
777
+ profileCmd.command("diff <name>").description("Show what would change when activating a profile").action((name) => {
778
+ try {
779
+ const claudeDir = getClaudeDir(program.opts());
780
+ const diff = diffProfile(name, claudeDir);
781
+ if (!diff.toEnable.length && !diff.toDisable.length && !diff.toBlock.length && !diff.toUnblock.length) {
782
+ console.log(`Profile "${name}" matches current state \u2014 no changes needed.`);
783
+ return;
784
+ }
785
+ console.log(`Diff for profile "${name}":`);
786
+ if (diff.toEnable.length) console.log(` Enable (+): ${diff.toEnable.join(", ")}`);
787
+ if (diff.toDisable.length) console.log(` Disable (-): ${diff.toDisable.join(", ")}`);
788
+ if (diff.toBlock.length) console.log(` Block (-): ${diff.toBlock.join(", ")}`);
789
+ if (diff.toUnblock.length) console.log(` Unblock (+): ${diff.toUnblock.join(", ")}`);
790
+ } catch (err) {
791
+ console.error(`Error: ${err.message}`);
792
+ process.exit(1);
793
+ }
794
+ });
795
+ profileCmd.command("export <name>").description("Export a profile to stdout or a file").option("--out <file>", "Write to file instead of stdout").action((name, opts) => {
796
+ try {
797
+ const claudeDir = getClaudeDir(program.opts());
798
+ const json = exportProfile(name, claudeDir);
799
+ if (opts.out) {
800
+ fs6.writeFileSync(opts.out, json);
801
+ console.log(`Profile "${name}" exported to ${opts.out}`);
802
+ } else {
803
+ process.stdout.write(json + "\n");
804
+ }
805
+ } catch (err) {
806
+ console.error(`Error: ${err.message}`);
807
+ process.exit(1);
808
+ }
469
809
  });
470
- program.command("audit").description("Report duplicates, disabled skill counts, and plugin orphans").action(() => {
471
- const standalone = scanStandaloneSkills();
472
- const plugins = scanPlugins();
810
+ profileCmd.command("import <file>").description("Import a profile from a JSON file").action((file) => {
811
+ try {
812
+ const claudeDir = getClaudeDir(program.opts());
813
+ const json = fs6.readFileSync(file, "utf-8");
814
+ const name = importProfile(json, claudeDir);
815
+ console.log(`Profile "${name}" imported.`);
816
+ } catch (err) {
817
+ console.error(`Error: ${err.message}`);
818
+ process.exit(1);
819
+ }
820
+ });
821
+ profileCmd.command("validate <name>").description("Check a profile for ghost skills (no longer on disk)").action((name) => {
822
+ try {
823
+ const claudeDir = getClaudeDir(program.opts());
824
+ const result = validateProfile(name, claudeDir);
825
+ if (!result.ghostSkills.length) {
826
+ console.log(`Profile "${name}" is valid \u2014 all ${result.validSkills.length} skills found on disk.`);
827
+ return;
828
+ }
829
+ console.log(`Profile "${name}" has ${result.ghostSkills.length} ghost skill(s):`);
830
+ result.ghostSkills.forEach((s) => console.log(` ${s} (not found on disk)`));
831
+ if (result.validSkills.length) console.log(`${result.validSkills.length} skill(s) OK: ${result.validSkills.join(", ")}`);
832
+ } catch (err) {
833
+ console.error(`Error: ${err.message}`);
834
+ process.exit(1);
835
+ }
836
+ });
837
+ program.command("catalog").description("Generate ~/.claude/SKILLS.md catalog").option("--out <path>", "Write to custom path instead of ~/.claude/SKILLS.md").action((opts) => {
838
+ try {
839
+ const claudeDir = getClaudeDir(program.opts());
840
+ const content = generateCatalog(claudeDir);
841
+ if (opts.out) {
842
+ fs6.writeFileSync(opts.out, content);
843
+ console.log(`Catalog written to ${opts.out}`);
844
+ } else {
845
+ console.log("Catalog written to ~/.claude/SKILLS.md");
846
+ console.log("Tip: use @~/.claude/SKILLS.md in any Claude session to reference it.");
847
+ }
848
+ } catch (err) {
849
+ console.error(`Error: ${err.message}`);
850
+ process.exit(1);
851
+ }
852
+ });
853
+ program.command("audit").description("Report duplicates, disabled skill counts, and plugin orphans").option("--json", "Output as JSON").action((opts) => {
854
+ const claudeDir = getClaudeDir(program.opts());
855
+ const standalone = scanStandaloneSkills(claudeDir);
856
+ const plugins = scanPlugins(claudeDir);
473
857
  const standaloneNames = new Set(standalone.map((s) => s.name));
474
858
  const pluginBaseNames = plugins.flatMap((p) => p.skills.map((s) => {
475
859
  const parts = s.name.split(":");
@@ -478,6 +862,10 @@ program.command("audit").description("Report duplicates, disabled skill counts,
478
862
  const duplicates = [...standaloneNames].filter((n) => pluginBaseNames.includes(n));
479
863
  const disabledStandalone = standalone.filter((s) => s.status === "disabled");
480
864
  const disabledPlugins = plugins.filter((p) => p.status === "disabled");
865
+ if (opts.json) {
866
+ process.stdout.write(JSON.stringify({ duplicates, disabledStandalone: disabledStandalone.map((s) => s.name), disabledPlugins: disabledPlugins.map((p) => p.id) }, null, 2) + "\n");
867
+ return;
868
+ }
481
869
  let found = false;
482
870
  if (duplicates.length) {
483
871
  found = true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillswitch",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Manage Claude Code skills — profiles, disable/enable, catalog generation",
5
5
  "type": "module",
6
6
  "bin": {