skillswitch 0.1.2 → 0.1.4

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 +985 -126
  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 fs13 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();
@@ -163,37 +203,375 @@ async function setBlockedPlugins(pluginIds, reason, claudeDir = defaultClaudeDir
163
203
  await writeBlocklist(data, claudeDir);
164
204
  }
165
205
 
166
- // src/profiles.ts
206
+ // src/adapters/claude.ts
167
207
  import * as fs4 from "fs";
168
208
  import * as path4 from "path";
169
- import { homedir as homedir4 } from "os";
170
- var defaultClaudeDir4 = path4.join(homedir4(), ".claude");
209
+ import * as os3 from "os";
210
+ var ClaudeAdapter = class {
211
+ constructor(claudeDir = path4.join(os3.homedir(), ".claude")) {
212
+ this.claudeDir = claudeDir;
213
+ this.skillsDirs = [path4.join(claudeDir, "skills")];
214
+ }
215
+ claudeDir;
216
+ cliName = "claude";
217
+ displayName = "Claude Code";
218
+ skillsDirs;
219
+ isInstalled() {
220
+ return fs4.existsSync(this.claudeDir);
221
+ }
222
+ scanSkills() {
223
+ return scanStandaloneSkills(this.claudeDir).map((s) => ({
224
+ name: s.name,
225
+ status: s.status,
226
+ description: s.description
227
+ }));
228
+ }
229
+ disableSkill(name) {
230
+ disableSkill(name, this.claudeDir);
231
+ }
232
+ enableSkill(name) {
233
+ enableSkill(name, this.claudeDir);
234
+ }
235
+ };
236
+
237
+ // src/adapters/gemini.ts
238
+ import * as fs6 from "fs";
239
+ import * as path6 from "path";
240
+ import * as os4 from "os";
241
+
242
+ // src/adapters/helpers.ts
243
+ import * as fs5 from "fs";
244
+ import * as path5 from "path";
245
+ function parseFrontmatterField(frontmatter, field) {
246
+ return frontmatter.match(new RegExp(`^${field}:\\s*(.+)$`, "m"))?.[1]?.trim() ?? "";
247
+ }
248
+ function parseSkillMd(content) {
249
+ const m = content.match(/^---\n([\s\S]*?)\n---/);
250
+ if (!m) return { name: "", description: "" };
251
+ return { name: parseFrontmatterField(m[1], "name"), description: parseFrontmatterField(m[1], "description") };
252
+ }
253
+ function extractFirstLine(content) {
254
+ let inFm = false, fmClosed = false;
255
+ for (const line of content.split("\n")) {
256
+ if (!fmClosed && line.trim() === "---") {
257
+ inFm = !inFm;
258
+ if (!inFm) fmClosed = true;
259
+ continue;
260
+ }
261
+ if (inFm || !line.trim() || line.startsWith("#")) continue;
262
+ return line.trim();
263
+ }
264
+ return "";
265
+ }
266
+ function readDesc(filePath) {
267
+ try {
268
+ const content = fs5.readFileSync(filePath, "utf-8");
269
+ return parseSkillMd(content).description || extractFirstLine(content);
270
+ } catch {
271
+ return "";
272
+ }
273
+ }
274
+ function scanFlatSkills(dir, group) {
275
+ const skills = [];
276
+ const disabledDir2 = path5.join(dir, ".disabled");
277
+ for (const [d, status] of [[dir, "active"], [disabledDir2, "disabled"]]) {
278
+ if (!fs5.existsSync(d)) continue;
279
+ for (const file of fs5.readdirSync(d)) {
280
+ if (!file.endsWith(".md")) continue;
281
+ const fp = path5.join(d, file);
282
+ if (!fs5.statSync(fp).isFile()) continue;
283
+ skills.push({ name: file.slice(0, -3), status, description: readDesc(fp), ...group ? { group } : {} });
284
+ }
285
+ }
286
+ return skills;
287
+ }
288
+ function disableFlatSkill(name, dir) {
289
+ const src = path5.join(dir, `${name}.md`);
290
+ if (!fs5.existsSync(src)) throw new Error(`Skill "${name}" not found in ${dir}`);
291
+ const disDir = path5.join(dir, ".disabled");
292
+ fs5.mkdirSync(disDir, { recursive: true });
293
+ fs5.renameSync(src, path5.join(disDir, `${name}.md`));
294
+ }
295
+ function enableFlatSkill(name, dir) {
296
+ const src = path5.join(dir, ".disabled", `${name}.md`);
297
+ if (!fs5.existsSync(src)) throw new Error(`Skill "${name}" is not disabled`);
298
+ fs5.mkdirSync(dir, { recursive: true });
299
+ fs5.renameSync(src, path5.join(dir, `${name}.md`));
300
+ }
301
+ function scanDirSkills(dir, group) {
302
+ const skills = [];
303
+ const disabledDir2 = path5.join(dir, ".disabled");
304
+ for (const [d, status] of [[dir, "active"], [disabledDir2, "disabled"]]) {
305
+ if (!fs5.existsSync(d)) continue;
306
+ for (const entry of fs5.readdirSync(d)) {
307
+ if (entry === ".disabled") continue;
308
+ const ep = path5.join(d, entry);
309
+ if (!fs5.statSync(ep).isDirectory()) continue;
310
+ const skillMd = path5.join(ep, "SKILL.md");
311
+ let description = "";
312
+ if (fs5.existsSync(skillMd)) {
313
+ const content = fs5.readFileSync(skillMd, "utf-8");
314
+ description = parseSkillMd(content).description || extractFirstLine(content);
315
+ }
316
+ skills.push({ name: entry, status, description, ...group ? { group } : {} });
317
+ }
318
+ }
319
+ return skills;
320
+ }
321
+ function disableDirSkill(name, dir) {
322
+ const src = path5.join(dir, name);
323
+ if (!fs5.existsSync(src) || !fs5.statSync(src).isDirectory()) throw new Error(`Skill "${name}" not found in ${dir}`);
324
+ const disDir = path5.join(dir, ".disabled");
325
+ fs5.mkdirSync(disDir, { recursive: true });
326
+ fs5.renameSync(src, path5.join(disDir, name));
327
+ }
328
+ function enableDirSkill(name, dir) {
329
+ const src = path5.join(dir, ".disabled", name);
330
+ if (!fs5.existsSync(src)) throw new Error(`Skill "${name}" is not disabled`);
331
+ fs5.mkdirSync(dir, { recursive: true });
332
+ fs5.renameSync(src, path5.join(dir, name));
333
+ }
334
+ function findSkillDir(name, dirs, dirBased) {
335
+ for (const dir of dirs) {
336
+ const target = dirBased ? path5.join(dir, name) : path5.join(dir, `${name}.md`);
337
+ const disTarget = dirBased ? path5.join(dir, ".disabled", name) : path5.join(dir, ".disabled", `${name}.md`);
338
+ if (fs5.existsSync(target) || fs5.existsSync(disTarget)) return dir;
339
+ }
340
+ return null;
341
+ }
342
+
343
+ // src/adapters/gemini.ts
344
+ var HOME = os4.homedir();
345
+ var GeminiAdapter = class {
346
+ cliName = "gemini";
347
+ displayName = "Gemini CLI";
348
+ skillsDirs = [
349
+ path6.join(HOME, ".gemini", "skills"),
350
+ path6.join(HOME, ".agents", "skills")
351
+ ];
352
+ isInstalled() {
353
+ return fs6.existsSync(path6.join(HOME, ".gemini"));
354
+ }
355
+ scanSkills() {
356
+ const seen = /* @__PURE__ */ new Set();
357
+ const skills = [];
358
+ for (const [i, dir] of this.skillsDirs.entries()) {
359
+ const group = i === 0 ? void 0 : "shared";
360
+ for (const s of scanDirSkills(dir, group)) {
361
+ if (!seen.has(s.name)) {
362
+ seen.add(s.name);
363
+ skills.push(s);
364
+ }
365
+ }
366
+ }
367
+ return skills;
368
+ }
369
+ disableSkill(name) {
370
+ const dir = findSkillDir(name, this.skillsDirs, true);
371
+ if (!dir) throw new Error(`Skill "${name}" not found in Gemini skills directories`);
372
+ disableDirSkill(name, dir);
373
+ }
374
+ enableSkill(name) {
375
+ const dir = findSkillDir(name, this.skillsDirs, true);
376
+ if (!dir) throw new Error(`Skill "${name}" not found in Gemini skills directories`);
377
+ enableDirSkill(name, dir);
378
+ }
379
+ };
380
+
381
+ // src/adapters/codex.ts
382
+ import * as fs7 from "fs";
383
+ import * as path7 from "path";
384
+ import * as os5 from "os";
385
+ var HOME2 = os5.homedir();
386
+ var CodexAdapter = class {
387
+ cliName = "codex";
388
+ displayName = "Codex CLI (OpenAI)";
389
+ skillsDirs = [
390
+ path7.join(HOME2, ".agents", "skills")
391
+ ];
392
+ isInstalled() {
393
+ return fs7.existsSync(path7.join(HOME2, ".codex"));
394
+ }
395
+ scanSkills() {
396
+ return scanDirSkills(this.skillsDirs[0]);
397
+ }
398
+ disableSkill(name) {
399
+ const dir = findSkillDir(name, this.skillsDirs, true);
400
+ if (!dir) throw new Error(`Skill "${name}" not found in Codex skills directory (${this.skillsDirs[0]})`);
401
+ disableDirSkill(name, dir);
402
+ }
403
+ enableSkill(name) {
404
+ const dir = findSkillDir(name, this.skillsDirs, true);
405
+ if (!dir) throw new Error(`Skill "${name}" not found in Codex skills directory`);
406
+ enableDirSkill(name, dir);
407
+ }
408
+ };
409
+
410
+ // src/adapters/factory.ts
411
+ import * as fs8 from "fs";
412
+ import * as path8 from "path";
413
+ import * as os6 from "os";
414
+ var HOME3 = os6.homedir();
415
+ var FACTORY_DIR = path8.join(HOME3, ".factory");
416
+ var FactoryAdapter = class {
417
+ cliName = "droid";
418
+ displayName = "Factory Droid";
419
+ skillsDirs = [
420
+ path8.join(FACTORY_DIR, "droids"),
421
+ path8.join(FACTORY_DIR, "commands")
422
+ ];
423
+ isInstalled() {
424
+ return fs8.existsSync(FACTORY_DIR);
425
+ }
426
+ scanSkills() {
427
+ return [
428
+ ...scanFlatSkills(this.skillsDirs[0], "droid"),
429
+ ...scanFlatSkills(this.skillsDirs[1], "command")
430
+ ];
431
+ }
432
+ disableSkill(name) {
433
+ const dir = findSkillDir(name, this.skillsDirs, false);
434
+ if (!dir) throw new Error(`Skill "${name}" not found in Factory Droid directories`);
435
+ disableFlatSkill(name, dir);
436
+ }
437
+ enableSkill(name) {
438
+ const dir = findSkillDir(name, this.skillsDirs, false);
439
+ if (!dir) throw new Error(`Skill "${name}" not found in Factory Droid directories`);
440
+ enableFlatSkill(name, dir);
441
+ }
442
+ };
443
+
444
+ // src/adapters/amp.ts
445
+ import * as fs9 from "fs";
446
+ import * as path9 from "path";
447
+ import * as os7 from "os";
448
+ var HOME4 = os7.homedir();
449
+ var AmpAdapter = class {
450
+ cliName = "amp";
451
+ displayName = "Amp (Sourcegraph)";
452
+ skillsDirs = [
453
+ path9.join(HOME4, ".config", "amp", "skills"),
454
+ path9.join(HOME4, ".agents", "skills")
455
+ ];
456
+ isInstalled() {
457
+ return fs9.existsSync(path9.join(HOME4, ".config", "amp"));
458
+ }
459
+ scanSkills() {
460
+ const seen = /* @__PURE__ */ new Set();
461
+ const skills = [];
462
+ for (const [i, dir] of this.skillsDirs.entries()) {
463
+ const group = i === 0 ? void 0 : "shared";
464
+ for (const s of scanDirSkills(dir, group)) {
465
+ if (!seen.has(s.name)) {
466
+ seen.add(s.name);
467
+ skills.push(s);
468
+ }
469
+ }
470
+ }
471
+ return skills;
472
+ }
473
+ disableSkill(name) {
474
+ const dir = findSkillDir(name, this.skillsDirs, true);
475
+ if (!dir) throw new Error(`Skill "${name}" not found in Amp skills directories`);
476
+ disableDirSkill(name, dir);
477
+ }
478
+ enableSkill(name) {
479
+ const dir = findSkillDir(name, this.skillsDirs, true);
480
+ if (!dir) throw new Error(`Skill "${name}" not found in Amp skills directories`);
481
+ enableDirSkill(name, dir);
482
+ }
483
+ };
484
+
485
+ // src/adapters/aider.ts
486
+ import * as fs10 from "fs";
487
+ import * as path10 from "path";
488
+ import * as os8 from "os";
489
+ var HOME5 = os8.homedir();
490
+ var AIDER_SKILLS_DIR = path10.join(HOME5, ".aider", "skills");
491
+ var AIDER_CONF = path10.join(HOME5, ".aider.conf.yml");
492
+ var AiderAdapter = class {
493
+ cliName = "aider";
494
+ displayName = "Aider";
495
+ skillsDirs = [AIDER_SKILLS_DIR];
496
+ isInstalled() {
497
+ return fs10.existsSync(AIDER_CONF) || fs10.existsSync(path10.join(HOME5, ".aider"));
498
+ }
499
+ scanSkills() {
500
+ return scanFlatSkills(AIDER_SKILLS_DIR);
501
+ }
502
+ disableSkill(name) {
503
+ disableFlatSkill(name, AIDER_SKILLS_DIR);
504
+ }
505
+ enableSkill(name) {
506
+ enableFlatSkill(name, AIDER_SKILLS_DIR);
507
+ }
508
+ /** Returns the `read:` block to paste into ~/.aider.conf.yml */
509
+ generateAiderConfig() {
510
+ const active = this.scanSkills().filter((s) => s.status === "active");
511
+ if (!active.length) return "# No active aider skills\n";
512
+ return `read:
513
+ ${active.map((s) => ` - ${path10.join(AIDER_SKILLS_DIR, s.name + ".md")}`).join("\n")}
514
+ `;
515
+ }
516
+ };
517
+
518
+ // src/adapters/index.ts
519
+ import * as os9 from "os";
520
+ import * as path11 from "path";
521
+ var ADAPTERS = {
522
+ claude: (claudeDir) => new ClaudeAdapter(claudeDir ?? path11.join(os9.homedir(), ".claude")),
523
+ gemini: () => new GeminiAdapter(),
524
+ codex: () => new CodexAdapter(),
525
+ droid: () => new FactoryAdapter(),
526
+ amp: () => new AmpAdapter(),
527
+ aider: () => new AiderAdapter()
528
+ };
529
+ var CLI_NAMES = Object.keys(ADAPTERS);
530
+ function getAdapter(cli, claudeDir) {
531
+ const factory = ADAPTERS[cli.toLowerCase()];
532
+ if (!factory) throw new Error(`Unknown CLI "${cli}". Valid options: ${CLI_NAMES.join(", ")}`);
533
+ return factory(claudeDir);
534
+ }
535
+ function getAllAdapters() {
536
+ return CLI_NAMES.map((name) => ADAPTERS[name]());
537
+ }
538
+
539
+ // src/profiles.ts
540
+ import * as fs11 from "fs";
541
+ import * as path12 from "path";
542
+ import { homedir as homedir11 } from "os";
543
+ var defaultClaudeDir4 = path12.join(homedir11(), ".claude");
171
544
  function profileStorePath(claudeDir) {
172
- return path4.join(claudeDir, "skillctl", "profiles.json");
545
+ return path12.join(claudeDir, "skillctl", "profiles.json");
173
546
  }
174
547
  function readProfileStore(claudeDir = defaultClaudeDir4) {
175
548
  const filePath = profileStorePath(claudeDir);
176
549
  try {
177
- const raw = fs4.readFileSync(filePath, "utf-8");
550
+ const raw = fs11.readFileSync(filePath, "utf-8");
178
551
  return JSON.parse(raw);
179
552
  } catch (err) {
180
553
  if (err.code === "ENOENT") {
181
- return { active: null, profiles: {} };
554
+ return { active: null, previous: null, profiles: {} };
555
+ }
556
+ if (err instanceof SyntaxError) {
557
+ process.stderr.write("Warning: profiles.json is corrupt \u2014 resetting to empty store\n");
558
+ return { active: null, previous: null, profiles: {} };
182
559
  }
183
560
  throw err;
184
561
  }
185
562
  }
186
563
  function writeProfileStore(store, claudeDir) {
187
564
  const filePath = profileStorePath(claudeDir);
188
- fs4.mkdirSync(path4.dirname(filePath), { recursive: true });
565
+ fs11.mkdirSync(path12.dirname(filePath), { recursive: true });
189
566
  const tmp = filePath + ".tmp";
190
- fs4.writeFileSync(tmp, JSON.stringify(store, null, 2));
191
- fs4.renameSync(tmp, filePath);
567
+ fs11.writeFileSync(tmp, JSON.stringify(store, null, 2));
568
+ fs11.renameSync(tmp, filePath);
192
569
  }
193
570
  function saveProfile(name, skills, plugins, claudeDir = defaultClaudeDir4) {
194
571
  const store = readProfileStore(claudeDir);
195
572
  const profile = {
196
573
  created: store.profiles[name]?.created ?? (/* @__PURE__ */ new Date()).toISOString(),
574
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
197
575
  skills,
198
576
  plugins
199
577
  };
@@ -207,21 +585,122 @@ function deleteProfile(name, claudeDir = defaultClaudeDir4) {
207
585
  delete store.profiles[name];
208
586
  writeProfileStore(store, claudeDir);
209
587
  }
210
- async function activateProfile(name, claudeDir = defaultClaudeDir4) {
588
+ function renameProfile(oldName, newName, claudeDir = defaultClaudeDir4) {
589
+ const store = readProfileStore(claudeDir);
590
+ if (!store.profiles[oldName]) throw new Error(`Profile "${oldName}" does not exist`);
591
+ if (store.profiles[newName]) throw new Error(`Profile "${newName}" already exists`);
592
+ store.profiles[newName] = store.profiles[oldName];
593
+ delete store.profiles[oldName];
594
+ if (store.active === oldName) store.active = newName;
595
+ if (store.previous === oldName) store.previous = newName;
596
+ writeProfileStore(store, claudeDir);
597
+ }
598
+ function copyProfile(srcName, dstName, claudeDir = defaultClaudeDir4) {
599
+ const store = readProfileStore(claudeDir);
600
+ if (!store.profiles[srcName]) throw new Error(`Profile "${srcName}" does not exist`);
601
+ if (store.profiles[dstName]) throw new Error(`Profile "${dstName}" already exists`);
602
+ store.profiles[dstName] = {
603
+ ...store.profiles[srcName],
604
+ created: (/* @__PURE__ */ new Date()).toISOString(),
605
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
606
+ };
607
+ writeProfileStore(store, claudeDir);
608
+ }
609
+ function diffProfile(name, claudeDir = defaultClaudeDir4) {
211
610
  const store = readProfileStore(claudeDir);
212
611
  const profile = store.profiles[name];
213
- if (!profile) {
214
- throw new Error(`Profile "${name}" does not exist`);
612
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
613
+ const profileSkills = new Set(profile.skills);
614
+ const skillsDir2 = path12.join(claudeDir, "skills");
615
+ const disabledDir2 = path12.join(claudeDir, "skills", ".disabled");
616
+ const toDisable = [];
617
+ const toEnable = [];
618
+ if (fs11.existsSync(skillsDir2)) {
619
+ for (const file of fs11.readdirSync(skillsDir2)) {
620
+ if (!file.endsWith(".md") || !fs11.statSync(path12.join(skillsDir2, file)).isFile()) continue;
621
+ const n = file.slice(0, -3);
622
+ if (!profileSkills.has(n)) toDisable.push(n);
623
+ }
624
+ }
625
+ if (fs11.existsSync(disabledDir2)) {
626
+ for (const file of fs11.readdirSync(disabledDir2)) {
627
+ if (!file.endsWith(".md")) continue;
628
+ const n = file.slice(0, -3);
629
+ if (profileSkills.has(n)) toEnable.push(n);
630
+ }
215
631
  }
632
+ const profilePlugins = new Set(profile.plugins);
633
+ const toBlock = [];
634
+ const toUnblock = [];
635
+ try {
636
+ const raw = JSON.parse(fs11.readFileSync(path12.join(claudeDir, "plugins", "installed_plugins.json"), "utf-8"));
637
+ const installed = Object.keys(raw?.plugins ?? {});
638
+ let currentlyBlocked = /* @__PURE__ */ new Set();
639
+ try {
640
+ const blRaw = JSON.parse(fs11.readFileSync(path12.join(claudeDir, "plugins", "blocklist.json"), "utf-8"));
641
+ currentlyBlocked = new Set(blRaw.plugins.map((e) => e.plugin));
642
+ } catch {
643
+ }
644
+ for (const id of installed) {
645
+ if (!profilePlugins.has(id) && !currentlyBlocked.has(id)) toBlock.push(id);
646
+ if (profilePlugins.has(id) && currentlyBlocked.has(id)) toUnblock.push(id);
647
+ }
648
+ } catch {
649
+ }
650
+ return { toEnable, toDisable, toBlock, toUnblock };
651
+ }
652
+ function exportProfile(name, claudeDir = defaultClaudeDir4) {
653
+ const store = readProfileStore(claudeDir);
654
+ const profile = store.profiles[name];
655
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
656
+ return JSON.stringify({ name, ...profile }, null, 2);
657
+ }
658
+ function importProfile(jsonStr, claudeDir = defaultClaudeDir4) {
659
+ let parsed;
660
+ try {
661
+ parsed = JSON.parse(jsonStr);
662
+ } catch {
663
+ throw new Error("Invalid JSON in import file");
664
+ }
665
+ if (!parsed.name || !Array.isArray(parsed.skills) || !Array.isArray(parsed.plugins)) {
666
+ throw new Error('Import file must have "name", "skills", and "plugins" fields');
667
+ }
668
+ saveProfile(parsed.name, parsed.skills, parsed.plugins, claudeDir);
669
+ return parsed.name;
670
+ }
671
+ function validateProfile(name, claudeDir = defaultClaudeDir4) {
672
+ const store = readProfileStore(claudeDir);
673
+ const profile = store.profiles[name];
674
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
675
+ const skillsDir2 = path12.join(claudeDir, "skills");
676
+ const disabledDir2 = path12.join(claudeDir, "skills", ".disabled");
677
+ const onDisk = /* @__PURE__ */ new Set();
678
+ for (const dir of [skillsDir2, disabledDir2]) {
679
+ if (!fs11.existsSync(dir)) continue;
680
+ for (const file of fs11.readdirSync(dir)) {
681
+ if (file.endsWith(".md")) onDisk.add(file.slice(0, -3));
682
+ }
683
+ }
684
+ const validSkills = [];
685
+ const ghostSkills = [];
686
+ for (const skill of profile.skills) {
687
+ (onDisk.has(skill) ? validSkills : ghostSkills).push(skill);
688
+ }
689
+ return { validSkills, ghostSkills };
690
+ }
691
+ async function activateProfile(name, claudeDir = defaultClaudeDir4) {
692
+ const store = readProfileStore(claudeDir);
693
+ const profile = store.profiles[name];
694
+ if (!profile) throw new Error(`Profile "${name}" does not exist`);
216
695
  const profileSkills = new Set(profile.skills);
217
- const skillsDir2 = path4.join(claudeDir, "skills");
218
- const disabledDir2 = path4.join(claudeDir, "skills", ".disabled");
696
+ const skillsDir2 = path12.join(claudeDir, "skills");
697
+ const disabledDir2 = path12.join(claudeDir, "skills", ".disabled");
219
698
  const enabled = [];
220
699
  const disabled = [];
221
- if (fs4.existsSync(skillsDir2)) {
222
- for (const file of fs4.readdirSync(skillsDir2)) {
700
+ if (fs11.existsSync(skillsDir2)) {
701
+ for (const file of fs11.readdirSync(skillsDir2)) {
223
702
  if (!file.endsWith(".md")) continue;
224
- const stat = fs4.statSync(path4.join(skillsDir2, file));
703
+ const stat = fs11.statSync(path12.join(skillsDir2, file));
225
704
  if (!stat.isFile()) continue;
226
705
  const skillName = file.slice(0, -3);
227
706
  if (!profileSkills.has(skillName)) {
@@ -230,8 +709,8 @@ async function activateProfile(name, claudeDir = defaultClaudeDir4) {
230
709
  }
231
710
  }
232
711
  }
233
- if (fs4.existsSync(disabledDir2)) {
234
- for (const file of fs4.readdirSync(disabledDir2)) {
712
+ if (fs11.existsSync(disabledDir2)) {
713
+ for (const file of fs11.readdirSync(disabledDir2)) {
235
714
  if (!file.endsWith(".md")) continue;
236
715
  const skillName = file.slice(0, -3);
237
716
  if (profileSkills.has(skillName)) {
@@ -241,26 +720,27 @@ async function activateProfile(name, claudeDir = defaultClaudeDir4) {
241
720
  }
242
721
  }
243
722
  const profilePlugins = new Set(profile.plugins);
244
- const installedPath = path4.join(claudeDir, "plugins", "installed_plugins.json");
723
+ const installedPath = path12.join(claudeDir, "plugins", "installed_plugins.json");
245
724
  let pluginsBlocked = [];
246
725
  try {
247
- const raw = JSON.parse(fs4.readFileSync(installedPath, "utf-8"));
726
+ const raw = JSON.parse(fs11.readFileSync(installedPath, "utf-8"));
248
727
  const allInstalled = Object.keys(raw?.plugins ?? {});
249
728
  pluginsBlocked = allInstalled.filter((id) => !profilePlugins.has(id));
250
729
  } catch (err) {
251
730
  if (err.code !== "ENOENT") throw err;
252
731
  }
253
732
  await setBlockedPlugins(pluginsBlocked, `blocked by profile: ${name}`, claudeDir);
733
+ store.previous = store.active;
254
734
  store.active = name;
255
735
  writeProfileStore(store, claudeDir);
256
736
  return { enabled, disabled, pluginsBlocked };
257
737
  }
258
738
 
259
739
  // src/catalog.ts
260
- import * as fs5 from "fs";
261
- import * as path5 from "path";
262
- import * as os3 from "os";
263
- var defaultClaudeDir5 = path5.join(os3.homedir(), ".claude");
740
+ import * as fs12 from "fs";
741
+ import * as path13 from "path";
742
+ import * as os10 from "os";
743
+ var defaultClaudeDir5 = path13.join(os10.homedir(), ".claude");
264
744
  function generateCatalog(claudeDir = defaultClaudeDir5) {
265
745
  const standalone = scanStandaloneSkills(claudeDir);
266
746
  const plugins = scanPlugins(claudeDir);
@@ -290,13 +770,26 @@ function generateCatalog(claudeDir = defaultClaudeDir5) {
290
770
  sections.push(`## Plugin: ${p.name} \u2014 DISABLED (${p.skills.length})`, "| Skill | Description |", "|-------|-------------|", rows(p.skills), "");
291
771
  }
292
772
  const content = sections.join("\n");
293
- fs5.writeFileSync(path5.join(claudeDir, "SKILLS.md"), content);
773
+ fs12.writeFileSync(path13.join(claudeDir, "SKILLS.md"), content);
294
774
  return content;
295
775
  }
296
776
 
297
777
  // src/cli.ts
778
+ process.on("unhandledRejection", (err) => {
779
+ process.stderr.write(`Error: ${err.message ?? err}
780
+ `);
781
+ process.exit(1);
782
+ });
783
+ function getClaudeDir(opts) {
784
+ return opts["claudeDir"] ?? process.env["SKILLSWITCH_CLAUDE_DIR"] ?? defaultClaudeDir;
785
+ }
786
+ function validateProfileName(name) {
787
+ if (!name || name.trim() === "") throw new Error("Profile name cannot be empty");
788
+ if (name.length > 64) throw new Error("Profile name must be 64 characters or less");
789
+ if (/[/\\]/.test(name)) throw new Error('Profile name cannot contain "/" or "\\"');
790
+ }
298
791
  var program = new Command();
299
- program.name("skillswitch").description("Manage Claude Code skills").version("0.1.0");
792
+ program.name("skillswitch").description(`Manage AI CLI skills \u2014 Claude Code, Gemini CLI, Codex CLI, Factory Droid, Amp, Aider`).version("0.1.3").option("--claude-dir <path>", "Override the Claude config directory (default: ~/.claude)").option("--for <cli>", `Target CLI: ${CLI_NAMES.join(" | ")} (default: claude)`);
300
793
  function confirm(prompt) {
301
794
  return new Promise((resolve) => {
302
795
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
@@ -306,25 +799,84 @@ function confirm(prompt) {
306
799
  });
307
800
  });
308
801
  }
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;
802
+ 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) => {
803
+ const globalOpts = program.opts();
804
+ const cliTarget = globalOpts["for"] ?? "claude";
805
+ if (cliTarget !== "claude") {
806
+ const adapter = getAdapter(cliTarget);
807
+ const statusFilter2 = opts.disabled ? "disabled" : opts.enabled ? "active" : null;
808
+ let skills = adapter.scanSkills();
809
+ if (statusFilter2) skills = skills.filter((s) => s.status === statusFilter2);
810
+ if (opts.json) {
811
+ process.stdout.write(JSON.stringify(skills, null, 2) + "\n");
812
+ return;
813
+ }
814
+ console.log(`
815
+ ${adapter.displayName} skills (${skills.length}):`);
816
+ for (const s of skills) console.log(` ${s.name}${s.group ? ` [${s.group}]` : ""}${s.status === "disabled" ? " [disabled]" : ""}`);
817
+ return;
818
+ }
819
+ const claudeDir = getClaudeDir(globalOpts);
820
+ const standalone = scanStandaloneSkills(claudeDir);
821
+ const plugins = scanPlugins(claudeDir);
822
+ if (opts.json) {
823
+ const filter = opts.disabled ? "disabled" : opts.enabled ? "active" : null;
824
+ const ss2 = filter ? standalone.filter((s) => s.status === filter) : standalone;
825
+ const pp2 = filter ? plugins.filter((p) => p.status === (filter === "active" ? "active" : "disabled")) : plugins;
826
+ process.stdout.write(JSON.stringify({ standalone: ss2, plugins: pp2 }, null, 2) + "\n");
827
+ return;
828
+ }
829
+ const statusFilter = opts.disabled ? "disabled" : opts.enabled ? "active" : null;
830
+ const ss = statusFilter ? standalone.filter((s) => s.status === statusFilter) : standalone;
313
831
  console.log(`
314
832
  Standalone (${standalone.length} total):`);
315
833
  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(`
834
+ const pp = statusFilter ? plugins.filter((p) => p.status === statusFilter) : plugins;
835
+ for (const p of pp) {
836
+ console.log(`
318
837
  ${p.id}${p.status === "disabled" ? " [DISABLED]" : ""} (${p.skills.length} skills)`);
838
+ for (const s of p.skills) console.log(` ${s.name}${s.status === "disabled" ? " [disabled]" : ""}`);
839
+ }
319
840
  });
320
- program.command("search <query>").description("Search skills by name or description (substring match)").action((query) => {
841
+ 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) => {
842
+ const globalOpts = program.opts();
843
+ const cliTarget = globalOpts["for"] ?? "claude";
321
844
  const q = query.toLowerCase();
322
- const standalone = scanStandaloneSkills();
323
- const plugins = scanPlugins();
324
- const matches = [
845
+ if (cliTarget !== "claude") {
846
+ const adapter = getAdapter(cliTarget);
847
+ let matches2 = adapter.scanSkills().filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q));
848
+ if (opts.status) matches2 = matches2.filter((s) => s.status === opts.status);
849
+ if (opts.json) {
850
+ process.stdout.write(JSON.stringify(matches2, null, 2) + "\n");
851
+ return;
852
+ }
853
+ if (!matches2.length) {
854
+ console.log(`No ${adapter.displayName} skills matching "${query}".`);
855
+ return;
856
+ }
857
+ console.log(`
858
+ ${matches2.length} match(es) for "${query}" in ${adapter.displayName}:
859
+ `);
860
+ for (const s of matches2) {
861
+ console.log(` ${s.name} [${s.status}]${s.group ? ` (${s.group})` : ""}`);
862
+ if (s.description) console.log(` ${s.description}`);
863
+ }
864
+ return;
865
+ }
866
+ const claudeDir = getClaudeDir(globalOpts);
867
+ const standalone = scanStandaloneSkills(claudeDir);
868
+ const plugins = scanPlugins(claudeDir);
869
+ let matches = [
325
870
  ...standalone.filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q)),
326
871
  ...plugins.flatMap((p) => p.skills).filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q))
327
872
  ];
873
+ if (opts.status) {
874
+ matches = matches.filter((s) => s.status === opts.status);
875
+ }
876
+ if (opts.json) {
877
+ process.stdout.write(JSON.stringify(matches, null, 2) + "\n");
878
+ return;
879
+ }
328
880
  if (!matches.length) {
329
881
  console.log(`No skills matching "${query}".`);
330
882
  return;
@@ -337,101 +889,273 @@ ${matches.length} match(es) for "${query}":
337
889
  if (s.description) console.log(` ${s.description}`);
338
890
  }
339
891
  });
340
- program.command("status").description("Show active profile and skill counts").action(() => {
341
- const store = readProfileStore();
342
- const standalone = scanStandaloneSkills();
343
- const plugins = scanPlugins();
892
+ program.command("status").description("Show active profile and skill counts").option("--json", "Output as JSON").action((opts) => {
893
+ const globalOpts = program.opts();
894
+ const cliTarget = globalOpts["for"] ?? "claude";
895
+ if (cliTarget !== "claude") {
896
+ const adapter = getAdapter(cliTarget);
897
+ const skills = adapter.scanSkills();
898
+ const active2 = skills.filter((s) => s.status === "active").length;
899
+ const disabled2 = skills.filter((s) => s.status === "disabled").length;
900
+ if (opts.json) {
901
+ process.stdout.write(JSON.stringify({ cli: adapter.displayName, active: active2, disabled: disabled2, total: active2 + disabled2 }, null, 2) + "\n");
902
+ return;
903
+ }
904
+ console.log(`CLI : ${adapter.displayName}`);
905
+ console.log(`Enabled skills : ${active2}`);
906
+ console.log(`Disabled skills: ${disabled2}`);
907
+ console.log(`Total : ${active2 + disabled2}`);
908
+ return;
909
+ }
910
+ const claudeDir = getClaudeDir(globalOpts);
911
+ const store = readProfileStore(claudeDir);
912
+ const standalone = scanStandaloneSkills(claudeDir);
913
+ const plugins = scanPlugins(claudeDir);
344
914
  const active = standalone.filter((s) => s.status === "active").length + plugins.filter((p) => p.status === "active").reduce((n, p) => n + p.skills.length, 0);
345
915
  const disabled = standalone.filter((s) => s.status === "disabled").length + plugins.filter((p) => p.status === "disabled").reduce((n, p) => n + p.skills.length, 0);
916
+ if (opts.json) {
917
+ process.stdout.write(JSON.stringify({ activeProfile: store.active, active, disabled, total: active + disabled }, null, 2) + "\n");
918
+ return;
919
+ }
346
920
  console.log(`Active profile : ${store.active ?? "none"}`);
347
921
  console.log(`Enabled skills : ${active}`);
348
922
  console.log(`Disabled skills: ${disabled}`);
349
923
  console.log(`Total : ${active + disabled}`);
350
924
  });
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}`);
355
- return;
925
+ 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) => {
926
+ const globalOpts = program.opts();
927
+ const cliTarget = globalOpts["for"] ?? "claude";
928
+ const log = (msg) => {
929
+ if (!opts.quiet) console.log(msg);
930
+ };
931
+ if (cliTarget !== "claude") {
932
+ try {
933
+ const adapter = getAdapter(cliTarget);
934
+ const matches = adapter.scanSkills().filter((s) => {
935
+ const hit = opts.exact ? s.name === name : s.name.includes(name);
936
+ return hit && s.status === "active";
937
+ });
938
+ if (!matches.length) {
939
+ console.log(`No active ${adapter.displayName} skills matching "${name}".`);
940
+ return;
941
+ }
942
+ if (matches.length > 1) {
943
+ console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
944
+ if (!await confirm(`Disable all ${matches.length}? (y/N) `)) {
945
+ console.log("Aborted.");
946
+ return;
947
+ }
948
+ }
949
+ for (const s of matches) {
950
+ if (opts.dryRun) log(`[dry-run] Would disable: ${s.name}`);
951
+ else {
952
+ adapter.disableSkill(s.name);
953
+ log(`Disabled: ${s.name}`);
954
+ }
955
+ }
956
+ } catch (err) {
957
+ console.error(`Error: ${err.message}`);
958
+ process.exit(1);
356
959
  }
357
- await blockPlugin(name, "skillswitch: manually disabled");
358
- console.log(`Plugin blocked: ${name}`);
359
960
  return;
360
961
  }
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.");
962
+ const claudeDir = getClaudeDir(globalOpts);
963
+ try {
964
+ if (opts.plugin) {
965
+ const plugins = scanPlugins(claudeDir);
966
+ const matched = plugins.filter(
967
+ (p) => opts.exact ? p.id === name : p.id.includes(name)
968
+ );
969
+ if (!matched.length) {
970
+ console.log(`No plugins matching "${name}".`);
971
+ return;
972
+ }
973
+ if (matched.length > 1 && !opts.exact) {
974
+ console.log(`Matches: ${matched.map((p) => p.id).join(", ")}`);
975
+ if (!await confirm(`Disable all ${matched.length} plugins? (y/N) `)) {
976
+ console.log("Aborted.");
977
+ return;
978
+ }
979
+ }
980
+ for (const p of matched) {
981
+ if (opts.dryRun) log(`[dry-run] Would block plugin: ${p.id}`);
982
+ else {
983
+ await blockPlugin(p.id, "skillswitch: manually disabled", claudeDir);
984
+ log(`Plugin blocked: ${p.id}`);
985
+ }
986
+ }
370
987
  return;
371
988
  }
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}`);
989
+ const matches = scanStandaloneSkills(claudeDir).filter((s) => {
990
+ const hit = opts.exact ? s.name === name : s.name.includes(name);
991
+ return hit && s.status === "active";
992
+ });
993
+ if (!matches.length) {
994
+ console.log(`No active skills matching "${name}".`);
995
+ return;
378
996
  }
997
+ if (matches.length > 1) {
998
+ console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
999
+ if (!await confirm(`Disable all ${matches.length}? (y/N) `)) {
1000
+ console.log("Aborted.");
1001
+ return;
1002
+ }
1003
+ }
1004
+ for (const s of matches) {
1005
+ if (opts.dryRun) log(`[dry-run] Would disable: ${s.name}`);
1006
+ else {
1007
+ disableSkill(s.name, claudeDir);
1008
+ log(`Disabled: ${s.name}`);
1009
+ }
1010
+ }
1011
+ } catch (err) {
1012
+ console.error(`Error: ${err.message}`);
1013
+ process.exit(1);
379
1014
  }
380
1015
  });
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}`);
385
- return;
1016
+ 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) => {
1017
+ const globalOpts = program.opts();
1018
+ const cliTarget = globalOpts["for"] ?? "claude";
1019
+ const log = (msg) => {
1020
+ if (!opts.quiet) console.log(msg);
1021
+ };
1022
+ if (cliTarget !== "claude") {
1023
+ try {
1024
+ const adapter = getAdapter(cliTarget);
1025
+ const matches = adapter.scanSkills().filter((s) => {
1026
+ const hit = opts.exact ? s.name === name : s.name.includes(name);
1027
+ return hit && s.status === "disabled";
1028
+ });
1029
+ if (!matches.length) {
1030
+ console.log(`No disabled ${adapter.displayName} skills matching "${name}".`);
1031
+ return;
1032
+ }
1033
+ if (matches.length > 1) {
1034
+ console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
1035
+ if (!await confirm(`Enable all ${matches.length}? (y/N) `)) {
1036
+ console.log("Aborted.");
1037
+ return;
1038
+ }
1039
+ }
1040
+ for (const s of matches) {
1041
+ if (opts.dryRun) log(`[dry-run] Would enable: ${s.name}`);
1042
+ else {
1043
+ adapter.enableSkill(s.name);
1044
+ log(`Enabled: ${s.name}`);
1045
+ }
1046
+ }
1047
+ } catch (err) {
1048
+ console.error(`Error: ${err.message}`);
1049
+ process.exit(1);
386
1050
  }
387
- await unblockPlugin(name);
388
- console.log(`Plugin unblocked: ${name}`);
389
1051
  return;
390
1052
  }
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.");
1053
+ const claudeDir = getClaudeDir(globalOpts);
1054
+ try {
1055
+ if (opts.plugin) {
1056
+ const plugins = scanPlugins(claudeDir);
1057
+ const matched = plugins.filter(
1058
+ (p) => opts.exact ? p.id === name : p.id.includes(name)
1059
+ );
1060
+ if (!matched.length) {
1061
+ console.log(`No plugins matching "${name}".`);
1062
+ return;
1063
+ }
1064
+ for (const p of matched) {
1065
+ if (opts.dryRun) {
1066
+ log(`[dry-run] Would unblock plugin: ${p.id}`);
1067
+ continue;
1068
+ }
1069
+ const wasBlocked = await unblockPlugin(p.id, claudeDir);
1070
+ log(wasBlocked ? `Plugin unblocked: ${p.id}` : `Plugin was not blocked: ${p.id}`);
1071
+ }
400
1072
  return;
401
1073
  }
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}`);
1074
+ const matches = scanStandaloneSkills(claudeDir).filter((s) => {
1075
+ const hit = opts.exact ? s.name === name : s.name.includes(name);
1076
+ return hit && s.status === "disabled";
1077
+ });
1078
+ if (!matches.length) {
1079
+ console.log(`No disabled skills matching "${name}".`);
1080
+ return;
1081
+ }
1082
+ if (matches.length > 1) {
1083
+ console.log(`Matches: ${matches.map((s) => s.name).join(", ")}`);
1084
+ if (!await confirm(`Enable all ${matches.length}? (y/N) `)) {
1085
+ console.log("Aborted.");
1086
+ return;
1087
+ }
1088
+ }
1089
+ for (const s of matches) {
1090
+ if (opts.dryRun) log(`[dry-run] Would enable: ${s.name}`);
1091
+ else {
1092
+ enableSkill(s.name, claudeDir);
1093
+ log(`Enabled: ${s.name}`);
1094
+ }
408
1095
  }
1096
+ } catch (err) {
1097
+ console.error(`Error: ${err.message}`);
1098
+ process.exit(1);
409
1099
  }
410
1100
  });
411
1101
  var profileCmd = program.command("profile").description("Manage skill profiles");
412
1102
  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.`);
1103
+ try {
1104
+ const claudeDir = getClaudeDir(program.opts());
1105
+ validateProfileName(name);
1106
+ const skills = scanStandaloneSkills(claudeDir).filter((s) => s.status === "active").map((s) => s.name);
1107
+ const plugins = scanPlugins(claudeDir).filter((p) => p.status === "active").map((p) => p.id);
1108
+ const store = readProfileStore(claudeDir);
1109
+ if (store.profiles[name]) console.log(`Overwriting existing profile "${name}".`);
1110
+ if (!skills.length && !plugins.length) console.warn(`Warning: no active skills or plugins found \u2014 profile may be empty by mistake.`);
1111
+ saveProfile(name, skills, plugins, claudeDir);
1112
+ console.log(`Profile "${name}" saved: ${skills.length} skills, ${plugins.length} plugins.`);
1113
+ } catch (err) {
1114
+ console.error(`Error: ${err.message}`);
1115
+ process.exit(1);
1116
+ }
417
1117
  });
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.`);
1118
+ 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) => {
1119
+ const claudeDir = getClaudeDir(program.opts());
1120
+ try {
1121
+ if (opts.dryRun) {
1122
+ const store = readProfileStore(claudeDir);
1123
+ const p = store.profiles[name];
1124
+ if (!p) {
1125
+ console.log(`Profile "${name}" not found.`);
1126
+ return;
1127
+ }
1128
+ const diff = diffProfile(name, claudeDir);
1129
+ console.log(`[dry-run] Activating "${name}":`);
1130
+ if (diff.toEnable.length) console.log(` Enable : ${diff.toEnable.join(", ")}`);
1131
+ if (diff.toDisable.length) console.log(` Disable : ${diff.toDisable.join(", ")}`);
1132
+ if (diff.toBlock.length) console.log(` Block : ${diff.toBlock.join(", ")}`);
1133
+ if (diff.toUnblock.length) console.log(` Unblock : ${diff.toUnblock.join(", ")}`);
1134
+ if (!diff.toEnable.length && !diff.toDisable.length && !diff.toBlock.length && !diff.toUnblock.length) {
1135
+ console.log(" (no changes)");
1136
+ }
424
1137
  return;
425
1138
  }
426
- console.log(`[dry-run] Would activate "${name}": ${p.skills.length} skills, ${p.plugins.length} plugins.`);
427
- return;
1139
+ const result = await activateProfile(name, claudeDir);
1140
+ if (!opts.quiet) {
1141
+ console.log(`Activated "${name}".`);
1142
+ if (result.enabled.length) console.log(` Enabled : ${result.enabled.join(", ")}`);
1143
+ if (result.disabled.length) console.log(` Disabled: ${result.disabled.join(", ")}`);
1144
+ if (result.pluginsBlocked.length) console.log(` Blocked : ${result.pluginsBlocked.join(", ")}`);
1145
+ }
1146
+ } catch (err) {
1147
+ console.error(`Error: ${err.message}`);
1148
+ process.exit(1);
428
1149
  }
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
1150
  });
432
- profileCmd.command("list").description("List saved profiles").action(() => {
433
- const store = readProfileStore();
1151
+ profileCmd.command("list").description("List saved profiles").option("--json", "Output as JSON").action((opts) => {
1152
+ const claudeDir = getClaudeDir(program.opts());
1153
+ const store = readProfileStore(claudeDir);
434
1154
  const names = Object.keys(store.profiles);
1155
+ if (opts.json) {
1156
+ process.stdout.write(JSON.stringify({ active: store.active, previous: store.previous, profiles: store.profiles }, null, 2) + "\n");
1157
+ return;
1158
+ }
435
1159
  if (!names.length) {
436
1160
  console.log("No profiles saved.");
437
1161
  return;
@@ -443,33 +1167,148 @@ profileCmd.command("list").description("List saved profiles").action(() => {
443
1167
  }
444
1168
  });
445
1169
  profileCmd.command("show <name>").description("Show skills in a profile").action((name) => {
446
- const store = readProfileStore();
1170
+ const claudeDir = getClaudeDir(program.opts());
1171
+ const store = readProfileStore(claudeDir);
447
1172
  const p = store.profiles[name];
448
1173
  if (!p) {
449
1174
  console.log(`Profile "${name}" not found.`);
450
1175
  return;
451
1176
  }
452
- console.log(`Profile "${name}" (${p.created.slice(0, 10)}):`);
1177
+ console.log(`Profile "${name}" (created ${p.created.slice(0, 10)}):`);
453
1178
  console.log(` Skills (${p.skills.length}): ${p.skills.join(", ") || "none"}`);
454
1179
  console.log(` Plugins (${p.plugins.length}): ${p.plugins.join(", ") || "none"}`);
455
1180
  });
456
- profileCmd.command("delete <name>").description("Delete a saved profile").action((name) => {
1181
+ profileCmd.command("delete <name>").description("Delete a saved profile").option("--force", "Delete even if this is the active profile").action((name, opts) => {
457
1182
  try {
458
- deleteProfile(name);
1183
+ const claudeDir = getClaudeDir(program.opts());
1184
+ if (opts.force) {
1185
+ const store = readProfileStore(claudeDir);
1186
+ if (store.active === name) {
1187
+ store.active = null;
1188
+ if (!store.profiles[name]) {
1189
+ console.log(`Profile "${name}" not found.`);
1190
+ return;
1191
+ }
1192
+ delete store.profiles[name];
1193
+ const storeFile = claudeDir + "/skillctl/profiles.json";
1194
+ const { mkdirSync: mkdirSync4, writeFileSync: writeFileSync4, renameSync: renameSync4 } = fs13;
1195
+ const dir = storeFile.replace(/\/[^/]+$/, "");
1196
+ mkdirSync4(dir, { recursive: true });
1197
+ writeFileSync4(storeFile + ".tmp", JSON.stringify(store, null, 2));
1198
+ renameSync4(storeFile + ".tmp", storeFile);
1199
+ console.log(`Profile "${name}" deleted.`);
1200
+ return;
1201
+ }
1202
+ }
1203
+ deleteProfile(name, claudeDir);
459
1204
  console.log(`Profile "${name}" deleted.`);
460
- } catch (e) {
461
- console.error(e.message);
1205
+ } catch (err) {
1206
+ console.error(err.message);
1207
+ process.exit(1);
1208
+ }
1209
+ });
1210
+ profileCmd.command("rename <old> <new>").description("Rename a profile").action((oldName, newName) => {
1211
+ try {
1212
+ const claudeDir = getClaudeDir(program.opts());
1213
+ validateProfileName(newName);
1214
+ renameProfile(oldName, newName, claudeDir);
1215
+ console.log(`Profile "${oldName}" renamed to "${newName}".`);
1216
+ } catch (err) {
1217
+ console.error(`Error: ${err.message}`);
462
1218
  process.exit(1);
463
1219
  }
464
1220
  });
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.");
1221
+ profileCmd.command("copy <src> <dst>").description("Copy a profile to a new name").action((srcName, dstName) => {
1222
+ try {
1223
+ const claudeDir = getClaudeDir(program.opts());
1224
+ validateProfileName(dstName);
1225
+ copyProfile(srcName, dstName, claudeDir);
1226
+ console.log(`Profile "${srcName}" copied to "${dstName}".`);
1227
+ } catch (err) {
1228
+ console.error(`Error: ${err.message}`);
1229
+ process.exit(1);
1230
+ }
469
1231
  });
470
- program.command("audit").description("Report duplicates, disabled skill counts, and plugin orphans").action(() => {
471
- const standalone = scanStandaloneSkills();
472
- const plugins = scanPlugins();
1232
+ profileCmd.command("diff <name>").description("Show what would change when activating a profile").action((name) => {
1233
+ try {
1234
+ const claudeDir = getClaudeDir(program.opts());
1235
+ const diff = diffProfile(name, claudeDir);
1236
+ if (!diff.toEnable.length && !diff.toDisable.length && !diff.toBlock.length && !diff.toUnblock.length) {
1237
+ console.log(`Profile "${name}" matches current state \u2014 no changes needed.`);
1238
+ return;
1239
+ }
1240
+ console.log(`Diff for profile "${name}":`);
1241
+ if (diff.toEnable.length) console.log(` Enable (+): ${diff.toEnable.join(", ")}`);
1242
+ if (diff.toDisable.length) console.log(` Disable (-): ${diff.toDisable.join(", ")}`);
1243
+ if (diff.toBlock.length) console.log(` Block (-): ${diff.toBlock.join(", ")}`);
1244
+ if (diff.toUnblock.length) console.log(` Unblock (+): ${diff.toUnblock.join(", ")}`);
1245
+ } catch (err) {
1246
+ console.error(`Error: ${err.message}`);
1247
+ process.exit(1);
1248
+ }
1249
+ });
1250
+ 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) => {
1251
+ try {
1252
+ const claudeDir = getClaudeDir(program.opts());
1253
+ const json = exportProfile(name, claudeDir);
1254
+ if (opts.out) {
1255
+ fs13.writeFileSync(opts.out, json);
1256
+ console.log(`Profile "${name}" exported to ${opts.out}`);
1257
+ } else {
1258
+ process.stdout.write(json + "\n");
1259
+ }
1260
+ } catch (err) {
1261
+ console.error(`Error: ${err.message}`);
1262
+ process.exit(1);
1263
+ }
1264
+ });
1265
+ profileCmd.command("import <file>").description("Import a profile from a JSON file").action((file) => {
1266
+ try {
1267
+ const claudeDir = getClaudeDir(program.opts());
1268
+ const json = fs13.readFileSync(file, "utf-8");
1269
+ const name = importProfile(json, claudeDir);
1270
+ console.log(`Profile "${name}" imported.`);
1271
+ } catch (err) {
1272
+ console.error(`Error: ${err.message}`);
1273
+ process.exit(1);
1274
+ }
1275
+ });
1276
+ profileCmd.command("validate <name>").description("Check a profile for ghost skills (no longer on disk)").action((name) => {
1277
+ try {
1278
+ const claudeDir = getClaudeDir(program.opts());
1279
+ const result = validateProfile(name, claudeDir);
1280
+ if (!result.ghostSkills.length) {
1281
+ console.log(`Profile "${name}" is valid \u2014 all ${result.validSkills.length} skills found on disk.`);
1282
+ return;
1283
+ }
1284
+ console.log(`Profile "${name}" has ${result.ghostSkills.length} ghost skill(s):`);
1285
+ result.ghostSkills.forEach((s) => console.log(` ${s} (not found on disk)`));
1286
+ if (result.validSkills.length) console.log(`${result.validSkills.length} skill(s) OK: ${result.validSkills.join(", ")}`);
1287
+ } catch (err) {
1288
+ console.error(`Error: ${err.message}`);
1289
+ process.exit(1);
1290
+ }
1291
+ });
1292
+ program.command("catalog").description("Generate ~/.claude/SKILLS.md catalog").option("--out <path>", "Write to custom path instead of ~/.claude/SKILLS.md").action((opts) => {
1293
+ try {
1294
+ const claudeDir = getClaudeDir(program.opts());
1295
+ const content = generateCatalog(claudeDir);
1296
+ if (opts.out) {
1297
+ fs13.writeFileSync(opts.out, content);
1298
+ console.log(`Catalog written to ${opts.out}`);
1299
+ } else {
1300
+ console.log("Catalog written to ~/.claude/SKILLS.md");
1301
+ console.log("Tip: use @~/.claude/SKILLS.md in any Claude session to reference it.");
1302
+ }
1303
+ } catch (err) {
1304
+ console.error(`Error: ${err.message}`);
1305
+ process.exit(1);
1306
+ }
1307
+ });
1308
+ program.command("audit").description("Report duplicates, disabled skill counts, and plugin orphans").option("--json", "Output as JSON").action((opts) => {
1309
+ const claudeDir = getClaudeDir(program.opts());
1310
+ const standalone = scanStandaloneSkills(claudeDir);
1311
+ const plugins = scanPlugins(claudeDir);
473
1312
  const standaloneNames = new Set(standalone.map((s) => s.name));
474
1313
  const pluginBaseNames = plugins.flatMap((p) => p.skills.map((s) => {
475
1314
  const parts = s.name.split(":");
@@ -478,6 +1317,10 @@ program.command("audit").description("Report duplicates, disabled skill counts,
478
1317
  const duplicates = [...standaloneNames].filter((n) => pluginBaseNames.includes(n));
479
1318
  const disabledStandalone = standalone.filter((s) => s.status === "disabled");
480
1319
  const disabledPlugins = plugins.filter((p) => p.status === "disabled");
1320
+ if (opts.json) {
1321
+ process.stdout.write(JSON.stringify({ duplicates, disabledStandalone: disabledStandalone.map((s) => s.name), disabledPlugins: disabledPlugins.map((p) => p.id) }, null, 2) + "\n");
1322
+ return;
1323
+ }
481
1324
  let found = false;
482
1325
  if (duplicates.length) {
483
1326
  found = true;
@@ -499,4 +1342,20 @@ Blocked plugins (${disabledPlugins.length}):`);
499
1342
  }
500
1343
  if (!found) console.log("No issues found.");
501
1344
  });
1345
+ program.command("detect").description("Show which supported AI CLIs are installed").option("--json", "Output as JSON").action((opts) => {
1346
+ const adapters = getAllAdapters();
1347
+ const results = adapters.map((a) => ({ cli: a.cliName, name: a.displayName, installed: a.isInstalled() }));
1348
+ if (opts.json) {
1349
+ process.stdout.write(JSON.stringify(results, null, 2) + "\n");
1350
+ return;
1351
+ }
1352
+ console.log("\nDetected CLIs:");
1353
+ for (const r of results) {
1354
+ console.log(` ${r.name.padEnd(20)} ${r.installed ? "installed" : "not found"}`);
1355
+ }
1356
+ });
1357
+ program.command("aider-config").description("Print the read: block to paste into ~/.aider.conf.yml for active Aider skills").action(() => {
1358
+ const adapter = new AiderAdapter();
1359
+ process.stdout.write(adapter.generateAiderConfig());
1360
+ });
502
1361
  program.parse();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "skillswitch",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Manage Claude Code skills — profiles, disable/enable, catalog generation",
5
5
  "type": "module",
6
6
  "bin": {