swe-workflow-skills 0.4.0 → 0.5.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 +22 -3
- package/VERSION +1 -1
- package/bin/cli.mjs +18 -2
- package/disable.mjs +207 -0
- package/install.mjs +13 -5
- package/package.json +2 -1
- package/scripts/resolve.mjs +77 -2
- package/uninstall.mjs +1 -0
package/README.md
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# SWE Workflow Skills for Claude Code
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/swe-workflow-skills)
|
|
3
4
|
[](https://github.com/SWEStash/swe-workflow-skills/actions/workflows/roles-check.yml)
|
|
4
5
|

|
|
5
6
|
[](LICENSE)
|
|
@@ -59,12 +60,28 @@ web, claude.ai chat, and Cowork):
|
|
|
59
60
|
/plugin install swe-workflow-pm@swe-workflow
|
|
60
61
|
```
|
|
61
62
|
|
|
62
|
-
**Want the whole library with the orchestrator** (CLI) — no clone needed
|
|
63
|
+
**Want the whole library with the orchestrator** (CLI) — no clone needed. This is a
|
|
64
|
+
**two-step** setup:
|
|
63
65
|
|
|
64
66
|
```bash
|
|
65
|
-
|
|
67
|
+
# 1. Install all 65 skills + router + /role + the hook script, and apply the baseline
|
|
68
|
+
npx swe-workflow-skills install --global
|
|
66
69
|
```
|
|
67
70
|
|
|
71
|
+
```jsonc
|
|
72
|
+
// 2. Wire the hook: merge the snippet the installer prints into your settings.json
|
|
73
|
+
// (the installer never edits settings.json for you — it just prints the block).
|
|
74
|
+
// Then start a new session; /doctor should show the hook registered.
|
|
75
|
+
{ "hooks": { "SessionStart": [ /* ...the printed block... */ ] } }
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
> ⚠️ **Don't skip step 2.** Step 1 alone installs the skills and prevents cropping, but
|
|
79
|
+
> the hook is what nudges Claude to consult `skill-router` first — **without it, skills
|
|
80
|
+
> won't auto-route** (the whole point of the library) and the baseline isn't re-asserted
|
|
81
|
+
> after `/compact`. If you deliberately want no hook, pass `--no-hook`; auto-routing stays
|
|
82
|
+
> off until a hook is wired. The **per-role plugin** path above needs no hook — it's
|
|
83
|
+
> self-contained.
|
|
84
|
+
|
|
68
85
|
Or from a clone: `node install.mjs --global`.
|
|
69
86
|
|
|
70
87
|
> **Prerequisite:** Node.js ≥ 18 (already present wherever Claude Code runs).
|
|
@@ -97,7 +114,9 @@ for every method × surface, and **[ROLES.md](docs/ROLES.md)** for the activatio
|
|
|
97
114
|
On any non-trivial task, Claude consults **`skill-router`** first; it reads the full
|
|
98
115
|
catalog and invokes the matching skill(s) by name, re-routing as the work changes phase.
|
|
99
116
|
The default SessionStart hook nudges Claude to do this automatically; you can also route
|
|
100
|
-
explicitly ("use the security-audit skill") or switch the promoted set with `/role`.
|
|
117
|
+
explicitly ("use the security-audit skill") or switch the promoted set with `/role`. Don't
|
|
118
|
+
want a particular skill routed at all? `npx swe-workflow-skills disable <skill>` opts it out
|
|
119
|
+
durably — see [disable a skill from routing](docs/ROLES.md#advanced-disable-a-skill-from-routing).
|
|
101
120
|
|
|
102
121
|
**A routed chain, by phase** — e.g. *"add OAuth login"*:
|
|
103
122
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.5.0
|
package/bin/cli.mjs
CHANGED
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
//
|
|
6
6
|
// npx swe-workflow-skills install [--global|--dir DIR|--role R|--no-hook|...]
|
|
7
7
|
// npx swe-workflow-skills uninstall [--global|--dir DIR|--dry-run|--yes]
|
|
8
|
+
// npx swe-workflow-skills disable <skill> [--off] [--global | --dir DIR]
|
|
9
|
+
// npx swe-workflow-skills enable <skill> [--global | --dir DIR]
|
|
10
|
+
// npx swe-workflow-skills list-disabled [--global | --dir DIR]
|
|
8
11
|
|
|
9
12
|
import { spawnSync } from "node:child_process";
|
|
10
13
|
import { dirname, join } from "node:path";
|
|
@@ -17,21 +20,34 @@ const [sub, ...rest] = process.argv.slice(2);
|
|
|
17
20
|
const HELP = `swe-workflow-skills <command> [options]
|
|
18
21
|
|
|
19
22
|
Commands:
|
|
20
|
-
install
|
|
21
|
-
uninstall
|
|
23
|
+
install Install skills into a Claude config dir (passes flags to install.mjs)
|
|
24
|
+
uninstall Remove the library (passes flags to uninstall.mjs)
|
|
25
|
+
disable <skill> Opt a skill out of routing/auto-trigger (advanced; --off to fully hide)
|
|
26
|
+
enable <skill> Re-enable a previously disabled skill
|
|
27
|
+
list-disabled List skills currently disabled
|
|
22
28
|
|
|
23
29
|
Examples:
|
|
24
30
|
npx swe-workflow-skills install --global
|
|
25
31
|
npx swe-workflow-skills install --role pm
|
|
26
32
|
npx swe-workflow-skills uninstall --global --dry-run
|
|
33
|
+
npx swe-workflow-skills disable data-modeling --global
|
|
34
|
+
npx swe-workflow-skills enable data-modeling --global
|
|
27
35
|
|
|
28
36
|
Run a command with --help to see its full option list.`;
|
|
29
37
|
|
|
38
|
+
// disable/enable/list-disabled all run through disable.mjs; map the CLI verb to
|
|
39
|
+
// the action positional it expects (list-disabled -> list).
|
|
40
|
+
const DISABLE_ACTIONS = { disable: "disable", enable: "enable", "list-disabled": "list" };
|
|
41
|
+
|
|
30
42
|
if (sub === "install" || sub === "uninstall") {
|
|
31
43
|
const script = join(ROOT, sub === "install" ? "install.mjs" : "uninstall.mjs");
|
|
32
44
|
// stdio inherited so install output and uninstall's confirm prompt pass through.
|
|
33
45
|
const res = spawnSync(process.execPath, [script, ...rest], { stdio: "inherit" });
|
|
34
46
|
process.exit(res.status ?? 1);
|
|
47
|
+
} else if (sub in DISABLE_ACTIONS) {
|
|
48
|
+
const script = join(ROOT, "disable.mjs");
|
|
49
|
+
const res = spawnSync(process.execPath, [script, DISABLE_ACTIONS[sub], ...rest], { stdio: "inherit" });
|
|
50
|
+
process.exit(res.status ?? 1);
|
|
35
51
|
} else if (sub === "-v" || sub === "--version" || sub === "version") {
|
|
36
52
|
process.stdout.write(readFileSync(join(ROOT, "VERSION"), "utf-8"));
|
|
37
53
|
process.exit(0);
|
package/disable.mjs
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Disable / enable a single skill from routing and auto-trigger, durably.
|
|
3
|
+
//
|
|
4
|
+
// The library installs a name-only baseline: skills don't auto-trigger, they're
|
|
5
|
+
// invoked by the skill-router. Disabling goes one step further — it hides a skill
|
|
6
|
+
// from the router too, for users who don't want the whole corpus routed. This is
|
|
7
|
+
// an ADVANCED opt-out of the routed SWE-methodology; most people don't need it.
|
|
8
|
+
//
|
|
9
|
+
// Why a command (not a manual settings edit): the SessionStart hook re-writes
|
|
10
|
+
// every installed skill's skillOverrides entry each session, so a hand edit is
|
|
11
|
+
// reverted. This records the choice in a `.disabled-skills` marker beside the
|
|
12
|
+
// skills, which the hook folds into the baseline every time — so it persists.
|
|
13
|
+
//
|
|
14
|
+
// Runs on Linux, macOS, and Windows using only Node.
|
|
15
|
+
//
|
|
16
|
+
// node disable.mjs disable <skill> # -> user-invocable-only (default)
|
|
17
|
+
// node disable.mjs disable <skill> --off # -> off (fully hidden)
|
|
18
|
+
// node disable.mjs enable <skill> # re-enable (back to the baseline)
|
|
19
|
+
// node disable.mjs list # show disabled skills
|
|
20
|
+
// node disable.mjs disable <skill> --global # target the user config dir
|
|
21
|
+
|
|
22
|
+
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import {
|
|
27
|
+
loadRoles,
|
|
28
|
+
applyBaseline,
|
|
29
|
+
installedSkills,
|
|
30
|
+
readDisabled,
|
|
31
|
+
writeDisabled,
|
|
32
|
+
DEFAULT_DISABLE_STATE,
|
|
33
|
+
} from "./scripts/resolve.mjs";
|
|
34
|
+
|
|
35
|
+
// Exit cleanly when stdout is closed early (e.g. piped to `head`/`grep`) instead
|
|
36
|
+
// of throwing an EPIPE stack trace.
|
|
37
|
+
process.stdout.on("error", (e) => {
|
|
38
|
+
if (e && e.code === "EPIPE") process.exit(0);
|
|
39
|
+
throw e;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const REPO_ROOT = dirname(fileURLToPath(import.meta.url));
|
|
43
|
+
|
|
44
|
+
const USAGE = `Usage: disable.mjs <disable|enable|list> [skill] [options]
|
|
45
|
+
|
|
46
|
+
Disable or re-enable a skill from routing/auto-trigger (advanced opt-out).
|
|
47
|
+
|
|
48
|
+
Actions:
|
|
49
|
+
disable <skill> Hide <skill> from the router. Default state: user-invocable-only
|
|
50
|
+
(you can still run it manually as /<skill>). Add --off to hide
|
|
51
|
+
it entirely (not even user-invocable).
|
|
52
|
+
enable <skill> Re-enable <skill> (return it to the name-only baseline).
|
|
53
|
+
list List currently disabled skills.
|
|
54
|
+
|
|
55
|
+
Options:
|
|
56
|
+
--off With 'disable': set state to "off" instead of user-invocable-only
|
|
57
|
+
-g, --global Target the user config dir: $CLAUDE_CONFIG_DIR if set, else
|
|
58
|
+
~/.claude/ (default without this flag: ./.claude/)
|
|
59
|
+
-d, --dir DIR Target a custom Claude config directory DIR
|
|
60
|
+
-h, --help Show this help`;
|
|
61
|
+
|
|
62
|
+
function isDir(p) {
|
|
63
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
64
|
+
}
|
|
65
|
+
function fatal(msg) {
|
|
66
|
+
process.stderr.write(`Error: ${msg}\n`);
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
function log(msg) {
|
|
70
|
+
process.stdout.write(msg + "\n");
|
|
71
|
+
}
|
|
72
|
+
function expandTilde(p) {
|
|
73
|
+
if (p === "~" || p.startsWith("~/") || p.startsWith("~\\")) return homedir() + p.slice(1);
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- arg parsing -----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
let global = false;
|
|
80
|
+
let off = false;
|
|
81
|
+
let configDir = "";
|
|
82
|
+
const positional = [];
|
|
83
|
+
|
|
84
|
+
const argv = process.argv.slice(2);
|
|
85
|
+
for (let i = 0; i < argv.length; i++) {
|
|
86
|
+
const a = argv[i];
|
|
87
|
+
if (a === "-g" || a === "--global") global = true;
|
|
88
|
+
else if (a === "--off") off = true;
|
|
89
|
+
else if (a === "-d" || a === "--dir") {
|
|
90
|
+
configDir = argv[++i];
|
|
91
|
+
if (configDir === undefined) fatal("--dir requires a path");
|
|
92
|
+
} else if (a.startsWith("--dir=")) configDir = a.slice("--dir=".length);
|
|
93
|
+
else if (a === "-h" || a === "--help") {
|
|
94
|
+
log(USAGE);
|
|
95
|
+
process.exit(0);
|
|
96
|
+
} else if (a.startsWith("-")) {
|
|
97
|
+
process.stderr.write(`Unknown option: ${a}\n${USAGE}\n`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
} else positional.push(a);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const action = positional[0];
|
|
103
|
+
const skill = positional[1];
|
|
104
|
+
if (!action || !["disable", "enable", "list"].includes(action)) {
|
|
105
|
+
process.stderr.write(`${USAGE}\n`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
if ((action === "disable" || action === "enable") && !skill) {
|
|
109
|
+
fatal(`'${action}' requires a <skill> name`);
|
|
110
|
+
}
|
|
111
|
+
// Reject path-like / malformed names before any filesystem use.
|
|
112
|
+
if (skill && !/^[a-z0-9][a-z0-9-]*$/.test(skill)) {
|
|
113
|
+
fatal(`invalid skill name '${skill}' (expected lowercase letters, digits, hyphens)`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let claudeDir;
|
|
117
|
+
if (configDir) {
|
|
118
|
+
if (global) fatal("--dir and --global are mutually exclusive");
|
|
119
|
+
claudeDir = resolve(expandTilde(configDir));
|
|
120
|
+
} else if (global) {
|
|
121
|
+
claudeDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
|
|
122
|
+
} else {
|
|
123
|
+
claudeDir = join(process.cwd(), ".claude");
|
|
124
|
+
}
|
|
125
|
+
const dest = join(claudeDir, "skills");
|
|
126
|
+
const settingsLocal = join(claudeDir, "settings.local.json");
|
|
127
|
+
|
|
128
|
+
if (!isDir(dest)) fatal(`no skills found at ${dest} (is the library installed there? try --global or --dir)`);
|
|
129
|
+
|
|
130
|
+
// Roles data: prefer the installed marker beside the skills, else the repo copy.
|
|
131
|
+
function loadRolesData() {
|
|
132
|
+
for (const p of [join(dest, ".roles.json"), join(REPO_ROOT, "roles.json")]) {
|
|
133
|
+
if (existsSync(p)) return loadRoles(p);
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Active role, so re-applying the baseline preserves the user's promoted set.
|
|
139
|
+
function activeRole(data) {
|
|
140
|
+
const f = join(dest, ".active-role");
|
|
141
|
+
if (!existsSync(f)) return null;
|
|
142
|
+
try {
|
|
143
|
+
const r = readFileSync(f, "utf-8").replace(/\s+/g, "");
|
|
144
|
+
return r && data && data.roles && r in data.roles ? r : null;
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Re-assert the baseline now so the change hot-reloads in the current session
|
|
151
|
+
// (like /role), instead of waiting for the next session boundary.
|
|
152
|
+
function reapply() {
|
|
153
|
+
const data = loadRolesData();
|
|
154
|
+
if (!data) return; // no roles data (partial/plugin install): marker still written
|
|
155
|
+
try {
|
|
156
|
+
applyBaseline(data, settingsLocal, dest, activeRole(data));
|
|
157
|
+
} catch (e) {
|
|
158
|
+
process.stderr.write(`Warning: wrote the marker but could not re-apply the baseline (${e.message}).\n`);
|
|
159
|
+
process.stderr.write("It will take effect at the next session start.\n");
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- actions ---------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
const disabled = readDisabled(dest);
|
|
166
|
+
|
|
167
|
+
if (action === "list") {
|
|
168
|
+
if (disabled.size === 0) {
|
|
169
|
+
log("No skills are disabled.");
|
|
170
|
+
} else {
|
|
171
|
+
log("Disabled skills:");
|
|
172
|
+
for (const name of [...disabled.keys()].sort()) log(` ${name} (${disabled.get(name)})`);
|
|
173
|
+
}
|
|
174
|
+
process.exit(0);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const data = loadRolesData();
|
|
178
|
+
const installed = new Set(installedSkills(dest));
|
|
179
|
+
|
|
180
|
+
if (action === "disable") {
|
|
181
|
+
if (!installed.has(skill)) fatal(`unknown skill '${skill}' — not installed at ${dest}`);
|
|
182
|
+
const state = off ? "off" : DEFAULT_DISABLE_STATE;
|
|
183
|
+
disabled.set(skill, state);
|
|
184
|
+
writeDisabled(dest, disabled);
|
|
185
|
+
reapply();
|
|
186
|
+
log(`Disabled '${skill}' -> ${state}.`);
|
|
187
|
+
if (state === DEFAULT_DISABLE_STATE) {
|
|
188
|
+
log(`The router will no longer invoke it; you can still run it manually as /${skill}.`);
|
|
189
|
+
} else {
|
|
190
|
+
log("It is now fully hidden (not routable and not user-invocable).");
|
|
191
|
+
}
|
|
192
|
+
if (data && Array.isArray(data.pinned) && data.pinned.includes(skill)) {
|
|
193
|
+
log(`Note: '${skill}' is a pinned safety skill — disabling it opts out of that guardrail.`);
|
|
194
|
+
}
|
|
195
|
+
log("This persists across sessions (the hook re-asserts it). Re-enable with:");
|
|
196
|
+
log(` node disable.mjs enable ${skill}${global ? " --global" : ""}`);
|
|
197
|
+
} else {
|
|
198
|
+
// enable
|
|
199
|
+
if (!disabled.has(skill)) {
|
|
200
|
+
log(`'${skill}' is not disabled — nothing to do.`);
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
disabled.delete(skill);
|
|
204
|
+
writeDisabled(dest, disabled);
|
|
205
|
+
reapply();
|
|
206
|
+
log(`Enabled '${skill}' — back to the name-only baseline (routable again).`);
|
|
207
|
+
}
|
package/install.mjs
CHANGED
|
@@ -369,14 +369,22 @@ if (hook) {
|
|
|
369
369
|
hookPath = join(hookDestDir, "session-start.mjs");
|
|
370
370
|
log(`Installed hook script -> ${hookPath}`);
|
|
371
371
|
log("");
|
|
372
|
-
log("
|
|
373
|
-
log("
|
|
374
|
-
log(
|
|
372
|
+
log("========================================================================");
|
|
373
|
+
log("REQUIRED NEXT STEP — wire the router hook");
|
|
374
|
+
log("========================================================================");
|
|
375
|
+
log("Skills are installed and the name-only baseline is applied, so nothing");
|
|
376
|
+
log("will crop. But WITHOUT this step Claude won't be nudged to consult");
|
|
377
|
+
log("skill-router first, so skills won't auto-route — you lose the whole point");
|
|
378
|
+
log("of the library. The hook also re-asserts the baseline after /compact.");
|
|
379
|
+
log("");
|
|
380
|
+
log(`Merge this into ${join(claudeDir, "settings.json")}`);
|
|
381
|
+
log("(for your safety the installer does NOT edit settings.json for you):");
|
|
375
382
|
log("");
|
|
376
383
|
log(hookSnippet(hookPath));
|
|
377
384
|
log("");
|
|
378
|
-
log("
|
|
379
|
-
log("(
|
|
385
|
+
log("Then start a new session and run /doctor to confirm the hook is registered.");
|
|
386
|
+
log("(Deliberately not using the hook? Re-run with --no-hook; the baseline still");
|
|
387
|
+
log(" applies, but auto-routing stays off until you wire a hook.)");
|
|
380
388
|
}
|
|
381
389
|
}
|
|
382
390
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "swe-workflow-skills",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "A curated library of Claude Code Agent Skills covering the full software lifecycle, with orchestrator-routed activation that scales past the skill-listing budget.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"bin/cli.mjs",
|
|
11
11
|
"install.mjs",
|
|
12
12
|
"uninstall.mjs",
|
|
13
|
+
"disable.mjs",
|
|
13
14
|
"scripts/resolve.mjs",
|
|
14
15
|
"hooks/session-start.mjs",
|
|
15
16
|
"commands/role.md",
|
package/scripts/resolve.mjs
CHANGED
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
//
|
|
29
29
|
// roles.json is read from $ROLES_JSON if set, else ../roles.json relative to this file.
|
|
30
30
|
|
|
31
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from "node:fs";
|
|
31
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync, rmSync } from "node:fs";
|
|
32
32
|
import { dirname, join, resolve as resolvePath } from "node:path";
|
|
33
33
|
import { fileURLToPath } from "node:url";
|
|
34
34
|
|
|
@@ -88,6 +88,73 @@ export function installedSkills(skillsDir) {
|
|
|
88
88
|
.sort();
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// ---- disabled-skills marker ------------------------------------------------
|
|
92
|
+
//
|
|
93
|
+
// Lets a user opt a skill OUT of routing/auto-trigger and have it *survive* the
|
|
94
|
+
// SessionStart hook. A plain settings.local.json edit can't: applyBaseline owns
|
|
95
|
+
// every installed skill's entry and rewrites it each session. Instead the choice
|
|
96
|
+
// lives in a `.disabled-skills` marker beside the skills (like `.active-role`),
|
|
97
|
+
// which applyBaseline folds in on every write — so the hook re-asserts the
|
|
98
|
+
// disable rather than reverting it.
|
|
99
|
+
//
|
|
100
|
+
// Format: one skill per line, optional state after whitespace; `#` comments and
|
|
101
|
+
// blank lines ignored. Valid states are the two that hide a skill from the model:
|
|
102
|
+
// data-modeling -> user-invocable-only (default; human /name still works)
|
|
103
|
+
// data-modeling off -> off (fully hidden)
|
|
104
|
+
|
|
105
|
+
export const DISABLE_STATES = new Set(["user-invocable-only", "off"]);
|
|
106
|
+
export const DEFAULT_DISABLE_STATE = "user-invocable-only";
|
|
107
|
+
|
|
108
|
+
export function disabledMarkerPath(skillsDir) {
|
|
109
|
+
return join(skillsDir, ".disabled-skills");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Parse the marker into Map<skill, state>. Best-effort: a missing or unreadable
|
|
113
|
+
// file yields an empty map (never throws — the hook must not fail on it).
|
|
114
|
+
export function readDisabled(skillsDir) {
|
|
115
|
+
const path = disabledMarkerPath(skillsDir);
|
|
116
|
+
const map = new Map();
|
|
117
|
+
if (!(existsSync(path) && statSync(path).isFile())) return map;
|
|
118
|
+
let text = "";
|
|
119
|
+
try {
|
|
120
|
+
text = readFileSync(path, "utf-8");
|
|
121
|
+
} catch {
|
|
122
|
+
return map;
|
|
123
|
+
}
|
|
124
|
+
for (const raw of text.split("\n")) {
|
|
125
|
+
const line = raw.trim();
|
|
126
|
+
if (!line || line.startsWith("#")) continue;
|
|
127
|
+
const [name, state] = line.split(/\s+/);
|
|
128
|
+
if (!name) continue;
|
|
129
|
+
map.set(name, DISABLE_STATES.has(state) ? state : DEFAULT_DISABLE_STATE);
|
|
130
|
+
}
|
|
131
|
+
return map;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Same, but restricted to skills actually installed here (stale names dropped).
|
|
135
|
+
export function loadDisabled(skillsDir) {
|
|
136
|
+
const installed = new Set(installedSkills(skillsDir));
|
|
137
|
+
const map = new Map();
|
|
138
|
+
for (const [name, state] of readDisabled(skillsDir)) {
|
|
139
|
+
if (installed.has(name)) map.set(name, state);
|
|
140
|
+
}
|
|
141
|
+
return map;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Persist Map<skill, state>, sorted. Removes the marker entirely when empty so an
|
|
145
|
+
// enabled-everything state leaves no file behind (mirrors pruneSettings dropping
|
|
146
|
+
// an empty skillOverrides).
|
|
147
|
+
export function writeDisabled(skillsDir, map) {
|
|
148
|
+
const path = disabledMarkerPath(skillsDir);
|
|
149
|
+
const names = [...map.keys()].sort();
|
|
150
|
+
if (names.length === 0) {
|
|
151
|
+
if (existsSync(path)) rmSync(path);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
const lines = names.map((n) => (map.get(n) === DEFAULT_DISABLE_STATE ? n : `${n} ${map.get(n)}`));
|
|
155
|
+
writeFileSync(path, lines.join("\n") + "\n");
|
|
156
|
+
}
|
|
157
|
+
|
|
91
158
|
// ---- settings I/O ----------------------------------------------------------
|
|
92
159
|
|
|
93
160
|
// Recursively sort object keys so output is deterministic (mirrors Python's
|
|
@@ -143,11 +210,19 @@ export function applyBaseline(data, settingsPath, skillsDir, role) {
|
|
|
143
210
|
const merged = {};
|
|
144
211
|
for (const [k, v] of Object.entries(existing)) if (!installedSet.has(k)) merged[k] = v;
|
|
145
212
|
Object.assign(merged, desired);
|
|
213
|
+
|
|
214
|
+
// User-disabled skills override the baseline: an explicit opt-out wins over
|
|
215
|
+
// name-only AND over a pinned/role-promoted "on" (a pinned skill the user
|
|
216
|
+
// disabled becomes user-invocable-only, not on). Read from the marker each
|
|
217
|
+
// call so the hook re-asserts the disable instead of reverting it.
|
|
218
|
+
const disabled = loadDisabled(skillsDir);
|
|
219
|
+
for (const [s, state] of disabled) merged[s] = state;
|
|
220
|
+
|
|
146
221
|
settings.skillOverrides = merged;
|
|
147
222
|
|
|
148
223
|
writeSettings(settingsPath, settings);
|
|
149
224
|
const nameOnly = Object.keys(desired).length;
|
|
150
|
-
return { nameOnly, on: installed.length - nameOnly, role };
|
|
225
|
+
return { nameOnly, on: installed.length - nameOnly, role, disabled: disabled.size };
|
|
151
226
|
}
|
|
152
227
|
|
|
153
228
|
// Remove the given skills from a settings file's skillOverrides. Returns removed count.
|
package/uninstall.mjs
CHANGED
|
@@ -127,6 +127,7 @@ for (const f of [
|
|
|
127
127
|
join(dest, ".roles.json"),
|
|
128
128
|
join(dest, ".catalog.json"),
|
|
129
129
|
join(dest, ".active-role"),
|
|
130
|
+
join(dest, ".disabled-skills"),
|
|
130
131
|
join(dest, ".swe-workflow-manifest.json"),
|
|
131
132
|
join(claudeDir, "hooks", "resolve.mjs"),
|
|
132
133
|
join(claudeDir, "hooks", "session-start.mjs"),
|