skillswitch 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +72 -0
- package/dist/cli.js +502 -0
- package/package.json +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# skillctl
|
|
2
|
+
|
|
3
|
+
Manage Claude Code skills — profiles, disable/enable, and catalog generation.
|
|
4
|
+
|
|
5
|
+
Running 100+ Claude Code skills? Your context window is leaking. Claude Code injects every installed skill name into every session. With 450 skills, that's thousands of tokens burned before your first message.
|
|
6
|
+
|
|
7
|
+
`skillctl` fixes this.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g skillctl
|
|
13
|
+
# or run without installing:
|
|
14
|
+
npx skillctl <command>
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Commands
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
skillctl status # how many skills are active vs disabled
|
|
21
|
+
skillctl list # all skills by source
|
|
22
|
+
skillctl list --disabled # only disabled skills
|
|
23
|
+
skillctl search <query> # find skills by name or description
|
|
24
|
+
|
|
25
|
+
skillctl disable <name> # disable standalone skill (substring match)
|
|
26
|
+
skillctl disable --plugin <id> # disable entire plugin (e.g. aso-skills@aso-skills)
|
|
27
|
+
skillctl enable <name> # re-enable standalone skill
|
|
28
|
+
skillctl enable --plugin <id> # re-enable entire plugin
|
|
29
|
+
|
|
30
|
+
skillctl profile create <name> # snapshot current enabled set
|
|
31
|
+
skillctl profile use <name> # activate a profile
|
|
32
|
+
skillctl profile use <name> --dry-run # preview what would change
|
|
33
|
+
skillctl profile list # list saved profiles
|
|
34
|
+
skillctl profile show <name> # see what's in a profile
|
|
35
|
+
skillctl profile delete <name> # remove a profile
|
|
36
|
+
|
|
37
|
+
skillctl catalog # generate ~/.claude/SKILLS.md
|
|
38
|
+
skillctl audit # find duplicates and stale disabled skills
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Typical workflow
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
# Start with everything enabled (your current state)
|
|
45
|
+
skillctl profile create full # save the 450-skill state
|
|
46
|
+
|
|
47
|
+
# Trim down for dev work
|
|
48
|
+
skillctl disable --plugin aso-skills@aso-skills
|
|
49
|
+
skillctl disable --plugin claude-ads@agricidaniel-claude-ads
|
|
50
|
+
skillctl disable ads # disables all skills with "ads" in the name
|
|
51
|
+
skillctl profile create dev # save the lean set
|
|
52
|
+
|
|
53
|
+
# Switch contexts
|
|
54
|
+
skillctl profile use dev # 48 skills active
|
|
55
|
+
skillctl profile use full # back to 450
|
|
56
|
+
|
|
57
|
+
# Discover what you have
|
|
58
|
+
skillctl catalog # generates ~/.claude/SKILLS.md
|
|
59
|
+
# Then in Claude: @~/.claude/SKILLS.md
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
- **Standalone skills** (`~/.claude/skills/*.md`): disabled by moving to `.disabled/` subdirectory — always reversible, nothing deleted
|
|
65
|
+
- **Plugin skills** (`~/.claude/plugins/`): disabled by writing to Claude Code's native `blocklist.json` — works at plugin level
|
|
66
|
+
- **Profiles** stored in `~/.claude/skillctl/profiles.json`
|
|
67
|
+
|
|
68
|
+
No telemetry, no auth, no network — pure local filesystem tool.
|
|
69
|
+
|
|
70
|
+
## License
|
|
71
|
+
|
|
72
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
import * as readline from "readline";
|
|
6
|
+
|
|
7
|
+
// src/scanner.ts
|
|
8
|
+
import * as fs from "fs";
|
|
9
|
+
import * as path from "path";
|
|
10
|
+
import * as os from "os";
|
|
11
|
+
var defaultClaudeDir = path.join(os.homedir(), ".claude");
|
|
12
|
+
function extractDescription(content) {
|
|
13
|
+
const lines = content.split("\n");
|
|
14
|
+
let inFrontmatter = false;
|
|
15
|
+
let frontmatterClosed = false;
|
|
16
|
+
for (const line of lines) {
|
|
17
|
+
if (!frontmatterClosed && line.trim() === "---") {
|
|
18
|
+
inFrontmatter = !inFrontmatter;
|
|
19
|
+
if (!inFrontmatter) frontmatterClosed = true;
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
if (inFrontmatter) continue;
|
|
23
|
+
if (!line.trim() || line.startsWith("#")) continue;
|
|
24
|
+
return line.trim();
|
|
25
|
+
}
|
|
26
|
+
return "";
|
|
27
|
+
}
|
|
28
|
+
function scanStandaloneSkills(claudeDir = defaultClaudeDir) {
|
|
29
|
+
const skillsDir2 = path.join(claudeDir, "skills");
|
|
30
|
+
const disabledDir2 = path.join(skillsDir2, ".disabled");
|
|
31
|
+
const skills = [];
|
|
32
|
+
function readDir(dir, status) {
|
|
33
|
+
if (!fs.existsSync(dir)) return;
|
|
34
|
+
for (const file of fs.readdirSync(dir)) {
|
|
35
|
+
if (!file.endsWith(".md")) continue;
|
|
36
|
+
const filePath = path.join(dir, file);
|
|
37
|
+
if (!fs.statSync(filePath).isFile()) continue;
|
|
38
|
+
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) });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
readDir(skillsDir2, "active");
|
|
44
|
+
readDir(disabledDir2, "disabled");
|
|
45
|
+
return skills;
|
|
46
|
+
}
|
|
47
|
+
function scanPlugins(claudeDir = defaultClaudeDir) {
|
|
48
|
+
const pluginsDir = path.join(claudeDir, "plugins");
|
|
49
|
+
const installedFile = path.join(pluginsDir, "installed_plugins.json");
|
|
50
|
+
if (!fs.existsSync(installedFile)) return [];
|
|
51
|
+
const raw = JSON.parse(fs.readFileSync(installedFile, "utf-8"));
|
|
52
|
+
const pluginMap = raw.plugins ?? {};
|
|
53
|
+
const blockedSet = /* @__PURE__ */ new Set();
|
|
54
|
+
const blocklistFile = path.join(pluginsDir, "blocklist.json");
|
|
55
|
+
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);
|
|
58
|
+
}
|
|
59
|
+
return Object.keys(pluginMap).map((pluginId) => {
|
|
60
|
+
const atIdx = pluginId.indexOf("@");
|
|
61
|
+
const name = atIdx >= 0 ? pluginId.slice(0, atIdx) : pluginId;
|
|
62
|
+
const sourceMarket = atIdx >= 0 ? pluginId.slice(atIdx + 1) : "";
|
|
63
|
+
const status = blockedSet.has(pluginId) ? "disabled" : "active";
|
|
64
|
+
const cacheBase = path.join(pluginsDir, "cache", sourceMarket, name);
|
|
65
|
+
const pluginSkills = [];
|
|
66
|
+
if (fs.existsSync(cacheBase)) {
|
|
67
|
+
const versions = fs.readdirSync(cacheBase).filter((d) => fs.statSync(path.join(cacheBase, d)).isDirectory()).sort();
|
|
68
|
+
if (versions.length > 0) {
|
|
69
|
+
const skillsDir2 = path.join(cacheBase, versions[versions.length - 1], "skills");
|
|
70
|
+
if (fs.existsSync(skillsDir2)) collectPluginSkills(skillsDir2, name, pluginId, status, pluginSkills);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return { id: pluginId, name, sourceMarket, skills: pluginSkills, status };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function collectPluginSkills(dir, pluginName, pluginId, status, out) {
|
|
77
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
78
|
+
const entryPath = path.join(dir, entry);
|
|
79
|
+
if (fs.statSync(entryPath).isDirectory()) {
|
|
80
|
+
for (const sub of fs.readdirSync(entryPath)) {
|
|
81
|
+
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) });
|
|
84
|
+
}
|
|
85
|
+
} 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) });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/disable.ts
|
|
93
|
+
import * as fs2 from "fs";
|
|
94
|
+
import * as path2 from "path";
|
|
95
|
+
import * as os2 from "os";
|
|
96
|
+
var defaultClaudeDir2 = path2.join(os2.homedir(), ".claude");
|
|
97
|
+
var skillsDir = (d) => path2.join(d, "skills");
|
|
98
|
+
var disabledDir = (d) => path2.join(d, "skills", ".disabled");
|
|
99
|
+
function disableSkill(name, claudeDir = defaultClaudeDir2) {
|
|
100
|
+
const disDir = disabledDir(claudeDir);
|
|
101
|
+
if (fs2.existsSync(path2.join(disDir, `${name}.md`))) throw new Error(`Skill "${name}" is already disabled`);
|
|
102
|
+
const src = path2.join(skillsDir(claudeDir), `${name}.md`);
|
|
103
|
+
if (!fs2.existsSync(src)) throw new Error(`Skill "${name}" not found in skills directory`);
|
|
104
|
+
fs2.mkdirSync(disDir, { recursive: true });
|
|
105
|
+
fs2.renameSync(src, path2.join(disDir, `${name}.md`));
|
|
106
|
+
}
|
|
107
|
+
function enableSkill(name, claudeDir = defaultClaudeDir2) {
|
|
108
|
+
const src = path2.join(disabledDir(claudeDir), `${name}.md`);
|
|
109
|
+
if (!fs2.existsSync(src)) throw new Error(`Skill "${name}" is not in disabled directory`);
|
|
110
|
+
fs2.renameSync(src, path2.join(skillsDir(claudeDir), `${name}.md`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// src/blocklist.ts
|
|
114
|
+
import * as fs3 from "fs/promises";
|
|
115
|
+
import * as path3 from "path";
|
|
116
|
+
import { homedir as homedir3 } from "os";
|
|
117
|
+
var defaultClaudeDir3 = path3.join(homedir3(), ".claude");
|
|
118
|
+
function blocklistPath(claudeDir) {
|
|
119
|
+
return path3.join(claudeDir, "plugins", "blocklist.json");
|
|
120
|
+
}
|
|
121
|
+
async function readBlocklist(claudeDir = defaultClaudeDir3) {
|
|
122
|
+
const filePath = blocklistPath(claudeDir);
|
|
123
|
+
try {
|
|
124
|
+
const raw = await fs3.readFile(filePath, "utf-8");
|
|
125
|
+
const parsed = JSON.parse(raw);
|
|
126
|
+
const plugins = Array.isArray(parsed.plugins) ? parsed.plugins : [];
|
|
127
|
+
return { fetchedAt: parsed.fetchedAt ?? (/* @__PURE__ */ new Date()).toISOString(), plugins };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (err.code === "ENOENT") {
|
|
130
|
+
return { fetchedAt: (/* @__PURE__ */ new Date()).toISOString(), plugins: [] };
|
|
131
|
+
}
|
|
132
|
+
throw err;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
async function writeBlocklist(data, claudeDir) {
|
|
136
|
+
const filePath = blocklistPath(claudeDir);
|
|
137
|
+
await fs3.mkdir(path3.dirname(filePath), { recursive: true });
|
|
138
|
+
const tmp = filePath + ".tmp";
|
|
139
|
+
await fs3.writeFile(tmp, JSON.stringify(data, null, 2));
|
|
140
|
+
await fs3.rename(tmp, filePath);
|
|
141
|
+
}
|
|
142
|
+
async function blockPlugin(pluginId, reason, claudeDir = defaultClaudeDir3) {
|
|
143
|
+
const blocklist = await readBlocklist(claudeDir);
|
|
144
|
+
const alreadyBlocked = blocklist.plugins.some((e) => e.plugin === pluginId);
|
|
145
|
+
if (alreadyBlocked) return;
|
|
146
|
+
const entry = { plugin: pluginId, added_at: (/* @__PURE__ */ new Date()).toISOString(), reason };
|
|
147
|
+
blocklist.plugins.push(entry);
|
|
148
|
+
blocklist.fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
149
|
+
await writeBlocklist(blocklist, claudeDir);
|
|
150
|
+
}
|
|
151
|
+
async function unblockPlugin(pluginId, claudeDir = defaultClaudeDir3) {
|
|
152
|
+
const blocklist = await readBlocklist(claudeDir);
|
|
153
|
+
const filtered = blocklist.plugins.filter((e) => e.plugin !== pluginId);
|
|
154
|
+
if (filtered.length === blocklist.plugins.length) return;
|
|
155
|
+
blocklist.plugins = filtered;
|
|
156
|
+
blocklist.fetchedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
157
|
+
await writeBlocklist(blocklist, claudeDir);
|
|
158
|
+
}
|
|
159
|
+
async function setBlockedPlugins(pluginIds, reason, claudeDir = defaultClaudeDir3) {
|
|
160
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
161
|
+
const plugins = pluginIds.map((id) => ({ plugin: id, added_at: now, reason }));
|
|
162
|
+
const data = { fetchedAt: now, plugins };
|
|
163
|
+
await writeBlocklist(data, claudeDir);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// src/profiles.ts
|
|
167
|
+
import * as fs4 from "fs";
|
|
168
|
+
import * as path4 from "path";
|
|
169
|
+
import { homedir as homedir4 } from "os";
|
|
170
|
+
var defaultClaudeDir4 = path4.join(homedir4(), ".claude");
|
|
171
|
+
function profileStorePath(claudeDir) {
|
|
172
|
+
return path4.join(claudeDir, "skillctl", "profiles.json");
|
|
173
|
+
}
|
|
174
|
+
function readProfileStore(claudeDir = defaultClaudeDir4) {
|
|
175
|
+
const filePath = profileStorePath(claudeDir);
|
|
176
|
+
try {
|
|
177
|
+
const raw = fs4.readFileSync(filePath, "utf-8");
|
|
178
|
+
return JSON.parse(raw);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
if (err.code === "ENOENT") {
|
|
181
|
+
return { active: null, profiles: {} };
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
function writeProfileStore(store, claudeDir) {
|
|
187
|
+
const filePath = profileStorePath(claudeDir);
|
|
188
|
+
fs4.mkdirSync(path4.dirname(filePath), { recursive: true });
|
|
189
|
+
const tmp = filePath + ".tmp";
|
|
190
|
+
fs4.writeFileSync(tmp, JSON.stringify(store, null, 2));
|
|
191
|
+
fs4.renameSync(tmp, filePath);
|
|
192
|
+
}
|
|
193
|
+
function saveProfile(name, skills, plugins, claudeDir = defaultClaudeDir4) {
|
|
194
|
+
const store = readProfileStore(claudeDir);
|
|
195
|
+
const profile = {
|
|
196
|
+
created: store.profiles[name]?.created ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
197
|
+
skills,
|
|
198
|
+
plugins
|
|
199
|
+
};
|
|
200
|
+
store.profiles[name] = profile;
|
|
201
|
+
writeProfileStore(store, claudeDir);
|
|
202
|
+
}
|
|
203
|
+
function deleteProfile(name, claudeDir = defaultClaudeDir4) {
|
|
204
|
+
const store = readProfileStore(claudeDir);
|
|
205
|
+
if (store.active === name) throw new Error("Cannot delete active profile");
|
|
206
|
+
if (!store.profiles[name]) throw new Error(`Profile "${name}" does not exist`);
|
|
207
|
+
delete store.profiles[name];
|
|
208
|
+
writeProfileStore(store, claudeDir);
|
|
209
|
+
}
|
|
210
|
+
async function activateProfile(name, claudeDir = defaultClaudeDir4) {
|
|
211
|
+
const store = readProfileStore(claudeDir);
|
|
212
|
+
const profile = store.profiles[name];
|
|
213
|
+
if (!profile) {
|
|
214
|
+
throw new Error(`Profile "${name}" does not exist`);
|
|
215
|
+
}
|
|
216
|
+
const profileSkills = new Set(profile.skills);
|
|
217
|
+
const skillsDir2 = path4.join(claudeDir, "skills");
|
|
218
|
+
const disabledDir2 = path4.join(claudeDir, "skills", ".disabled");
|
|
219
|
+
const enabled = [];
|
|
220
|
+
const disabled = [];
|
|
221
|
+
if (fs4.existsSync(skillsDir2)) {
|
|
222
|
+
for (const file of fs4.readdirSync(skillsDir2)) {
|
|
223
|
+
if (!file.endsWith(".md")) continue;
|
|
224
|
+
const stat = fs4.statSync(path4.join(skillsDir2, file));
|
|
225
|
+
if (!stat.isFile()) continue;
|
|
226
|
+
const skillName = file.slice(0, -3);
|
|
227
|
+
if (!profileSkills.has(skillName)) {
|
|
228
|
+
disableSkill(skillName, claudeDir);
|
|
229
|
+
disabled.push(skillName);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (fs4.existsSync(disabledDir2)) {
|
|
234
|
+
for (const file of fs4.readdirSync(disabledDir2)) {
|
|
235
|
+
if (!file.endsWith(".md")) continue;
|
|
236
|
+
const skillName = file.slice(0, -3);
|
|
237
|
+
if (profileSkills.has(skillName)) {
|
|
238
|
+
enableSkill(skillName, claudeDir);
|
|
239
|
+
enabled.push(skillName);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const profilePlugins = new Set(profile.plugins);
|
|
244
|
+
const installedPath = path4.join(claudeDir, "plugins", "installed_plugins.json");
|
|
245
|
+
let pluginsBlocked = [];
|
|
246
|
+
try {
|
|
247
|
+
const raw = JSON.parse(fs4.readFileSync(installedPath, "utf-8"));
|
|
248
|
+
const allInstalled = Object.keys(raw?.plugins ?? {});
|
|
249
|
+
pluginsBlocked = allInstalled.filter((id) => !profilePlugins.has(id));
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (err.code !== "ENOENT") throw err;
|
|
252
|
+
}
|
|
253
|
+
await setBlockedPlugins(pluginsBlocked, `blocked by profile: ${name}`, claudeDir);
|
|
254
|
+
store.active = name;
|
|
255
|
+
writeProfileStore(store, claudeDir);
|
|
256
|
+
return { enabled, disabled, pluginsBlocked };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// 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");
|
|
264
|
+
function generateCatalog(claudeDir = defaultClaudeDir5) {
|
|
265
|
+
const standalone = scanStandaloneSkills(claudeDir);
|
|
266
|
+
const plugins = scanPlugins(claudeDir);
|
|
267
|
+
const activeStandalone = standalone.filter((s) => s.status === "active");
|
|
268
|
+
const disabledStandalone = standalone.filter((s) => s.status === "disabled");
|
|
269
|
+
const activePlugins = plugins.filter((p) => p.status === "active");
|
|
270
|
+
const disabledPlugins = plugins.filter((p) => p.status === "disabled");
|
|
271
|
+
const totalActive = activeStandalone.length + activePlugins.reduce((n, p) => n + p.skills.length, 0);
|
|
272
|
+
const totalDisabled = disabledStandalone.length + disabledPlugins.reduce((n, p) => n + p.skills.length, 0);
|
|
273
|
+
const escape = (s) => s.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
274
|
+
const rows = (skills) => skills.map((s) => `| ${escape(s.name)} | ${escape(s.description || "\u2014")} |`).join("\n");
|
|
275
|
+
const sections = [
|
|
276
|
+
`# Skills Catalog`,
|
|
277
|
+
`Generated: ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)} | Active: ${totalActive} | Disabled: ${totalDisabled}`,
|
|
278
|
+
""
|
|
279
|
+
];
|
|
280
|
+
if (activeStandalone.length > 0) {
|
|
281
|
+
sections.push(`## Standalone \u2014 active (${activeStandalone.length})`, "| Skill | Description |", "|-------|-------------|", rows(activeStandalone), "");
|
|
282
|
+
}
|
|
283
|
+
if (disabledStandalone.length > 0) {
|
|
284
|
+
sections.push(`## Standalone \u2014 disabled (${disabledStandalone.length})`, "| Skill | Description |", "|-------|-------------|", rows(disabledStandalone), "");
|
|
285
|
+
}
|
|
286
|
+
for (const p of activePlugins) {
|
|
287
|
+
sections.push(`## Plugin: ${p.name} \u2014 active (${p.skills.length})`, "| Skill | Description |", "|-------|-------------|", rows(p.skills), "");
|
|
288
|
+
}
|
|
289
|
+
for (const p of disabledPlugins) {
|
|
290
|
+
sections.push(`## Plugin: ${p.name} \u2014 DISABLED (${p.skills.length})`, "| Skill | Description |", "|-------|-------------|", rows(p.skills), "");
|
|
291
|
+
}
|
|
292
|
+
const content = sections.join("\n");
|
|
293
|
+
fs5.writeFileSync(path5.join(claudeDir, "SKILLS.md"), content);
|
|
294
|
+
return content;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// src/cli.ts
|
|
298
|
+
var program = new Command();
|
|
299
|
+
program.name("skillctl").description("Manage Claude Code skills").version("0.1.0");
|
|
300
|
+
function confirm(prompt) {
|
|
301
|
+
return new Promise((resolve) => {
|
|
302
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
303
|
+
rl.question(prompt, (a) => {
|
|
304
|
+
rl.close();
|
|
305
|
+
resolve(a.toLowerCase() === "y");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
}
|
|
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;
|
|
313
|
+
console.log(`
|
|
314
|
+
Standalone (${standalone.length} total):`);
|
|
315
|
+
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(`
|
|
318
|
+
${p.id}${p.status === "disabled" ? " [DISABLED]" : ""} (${p.skills.length} skills)`);
|
|
319
|
+
});
|
|
320
|
+
program.command("search <query>").description("Search skills by name or description (substring match)").action((query) => {
|
|
321
|
+
const q = query.toLowerCase();
|
|
322
|
+
const standalone = scanStandaloneSkills();
|
|
323
|
+
const plugins = scanPlugins();
|
|
324
|
+
const matches = [
|
|
325
|
+
...standalone.filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q)),
|
|
326
|
+
...plugins.flatMap((p) => p.skills).filter((s) => s.name.includes(q) || s.description.toLowerCase().includes(q))
|
|
327
|
+
];
|
|
328
|
+
if (!matches.length) {
|
|
329
|
+
console.log(`No skills matching "${query}".`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
console.log(`
|
|
333
|
+
${matches.length} match(es) for "${query}":
|
|
334
|
+
`);
|
|
335
|
+
for (const s of matches) {
|
|
336
|
+
console.log(` ${s.name} [${s.status}]`);
|
|
337
|
+
if (s.description) console.log(` ${s.description}`);
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
program.command("status").description("Show active profile and skill counts").action(() => {
|
|
341
|
+
const store = readProfileStore();
|
|
342
|
+
const standalone = scanStandaloneSkills();
|
|
343
|
+
const plugins = scanPlugins();
|
|
344
|
+
const active = standalone.filter((s) => s.status === "active").length + plugins.filter((p) => p.status === "active").reduce((n, p) => n + p.skills.length, 0);
|
|
345
|
+
const disabled = standalone.filter((s) => s.status === "disabled").length + plugins.filter((p) => p.status === "disabled").reduce((n, p) => n + p.skills.length, 0);
|
|
346
|
+
console.log(`Active profile : ${store.active ?? "none"}`);
|
|
347
|
+
console.log(`Enabled skills : ${active}`);
|
|
348
|
+
console.log(`Disabled skills: ${disabled}`);
|
|
349
|
+
console.log(`Total : ${active + disabled}`);
|
|
350
|
+
});
|
|
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;
|
|
356
|
+
}
|
|
357
|
+
await blockPlugin(name, "skillctl: 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.");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
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}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
});
|
|
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;
|
|
386
|
+
}
|
|
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.");
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
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}`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
var profileCmd = program.command("profile").description("Manage skill profiles");
|
|
412
|
+
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.`);
|
|
417
|
+
});
|
|
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.`);
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
console.log(`[dry-run] Would activate "${name}": ${p.skills.length} skills, ${p.plugins.length} plugins.`);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
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
|
+
});
|
|
432
|
+
profileCmd.command("list").description("List saved profiles").action(() => {
|
|
433
|
+
const store = readProfileStore();
|
|
434
|
+
const names = Object.keys(store.profiles);
|
|
435
|
+
if (!names.length) {
|
|
436
|
+
console.log("No profiles saved.");
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
for (const name of names) {
|
|
440
|
+
const p = store.profiles[name];
|
|
441
|
+
const tag = store.active === name ? " [active]" : "";
|
|
442
|
+
console.log(` ${name}${tag}: ${p.skills.length} skills, ${p.plugins.length} plugins`);
|
|
443
|
+
}
|
|
444
|
+
});
|
|
445
|
+
profileCmd.command("show <name>").description("Show skills in a profile").action((name) => {
|
|
446
|
+
const store = readProfileStore();
|
|
447
|
+
const p = store.profiles[name];
|
|
448
|
+
if (!p) {
|
|
449
|
+
console.log(`Profile "${name}" not found.`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
console.log(`Profile "${name}" (${p.created.slice(0, 10)}):`);
|
|
453
|
+
console.log(` Skills (${p.skills.length}): ${p.skills.join(", ") || "none"}`);
|
|
454
|
+
console.log(` Plugins (${p.plugins.length}): ${p.plugins.join(", ") || "none"}`);
|
|
455
|
+
});
|
|
456
|
+
profileCmd.command("delete <name>").description("Delete a saved profile").action((name) => {
|
|
457
|
+
try {
|
|
458
|
+
deleteProfile(name);
|
|
459
|
+
console.log(`Profile "${name}" deleted.`);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
console.error(e.message);
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
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.");
|
|
469
|
+
});
|
|
470
|
+
program.command("audit").description("Report duplicates, disabled skill counts, and plugin orphans").action(() => {
|
|
471
|
+
const standalone = scanStandaloneSkills();
|
|
472
|
+
const plugins = scanPlugins();
|
|
473
|
+
const standaloneNames = new Set(standalone.map((s) => s.name));
|
|
474
|
+
const pluginBaseNames = plugins.flatMap((p) => p.skills.map((s) => {
|
|
475
|
+
const parts = s.name.split(":");
|
|
476
|
+
return parts[parts.length - 1];
|
|
477
|
+
}));
|
|
478
|
+
const duplicates = [...standaloneNames].filter((n) => pluginBaseNames.includes(n));
|
|
479
|
+
const disabledStandalone = standalone.filter((s) => s.status === "disabled");
|
|
480
|
+
const disabledPlugins = plugins.filter((p) => p.status === "disabled");
|
|
481
|
+
let found = false;
|
|
482
|
+
if (duplicates.length) {
|
|
483
|
+
found = true;
|
|
484
|
+
console.log(`
|
|
485
|
+
Potential duplicates (standalone name matches plugin skill name):`);
|
|
486
|
+
duplicates.forEach((d) => console.log(` ${d}`));
|
|
487
|
+
}
|
|
488
|
+
if (disabledStandalone.length) {
|
|
489
|
+
found = true;
|
|
490
|
+
console.log(`
|
|
491
|
+
Disabled standalone skills (${disabledStandalone.length}) \u2014 review for removal:`);
|
|
492
|
+
disabledStandalone.forEach((s) => console.log(` ${s.name}`));
|
|
493
|
+
}
|
|
494
|
+
if (disabledPlugins.length) {
|
|
495
|
+
found = true;
|
|
496
|
+
console.log(`
|
|
497
|
+
Blocked plugins (${disabledPlugins.length}):`);
|
|
498
|
+
disabledPlugins.forEach((p) => console.log(` ${p.id}`));
|
|
499
|
+
}
|
|
500
|
+
if (!found) console.log("No issues found.");
|
|
501
|
+
});
|
|
502
|
+
program.parse();
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skillswitch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Manage Claude Code skills — profiles, disable/enable, catalog generation",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"skillctl": "./dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup src/cli.ts --format esm --clean",
|
|
15
|
+
"dev": "tsx src/cli.ts",
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"commander": "^12.1.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/node": "^20.0.0",
|
|
24
|
+
"tsup": "^8.0.0",
|
|
25
|
+
"tsx": "^4.0.0",
|
|
26
|
+
"typescript": "^5.0.0",
|
|
27
|
+
"vitest": "^1.6.0"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=18"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"claude",
|
|
35
|
+
"claude-code",
|
|
36
|
+
"skills",
|
|
37
|
+
"ai",
|
|
38
|
+
"cli"
|
|
39
|
+
],
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|