swe-workflow-skills 0.3.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 CHANGED
@@ -1,5 +1,6 @@
1
1
  # SWE Workflow Skills for Claude Code
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/swe-workflow-skills)](https://www.npmjs.com/package/swe-workflow-skills)
3
4
  [![roles-check](https://github.com/SWEStash/swe-workflow-skills/actions/workflows/roles-check.yml/badge.svg)](https://github.com/SWEStash/swe-workflow-skills/actions/workflows/roles-check.yml)
4
5
  ![skills](https://img.shields.io/badge/skills-65-blue)
5
6
  [![license: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](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
- npx swe-workflow-skills install --global # all 65 skills + router + /role + the SessionStart hook
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
 
@@ -144,6 +163,7 @@ review) live in the `skill-router` skill and **[ROLES.md](docs/ROLES.md)**.
144
163
  - **[EVALS.md](docs/EVALS.md)** — how the skills are tested (RED/GREEN, pressure tests, CI gate).
145
164
  - **[AUTHORING.md](docs/AUTHORING.md)** — write or modify a skill (descriptions, budget, progressive disclosure, evals).
146
165
  - **[RELEASING.md](docs/RELEASING.md)** — versioning policy and how to cut a release. Changes are tracked in **[CHANGELOG.md](CHANGELOG.md)**.
166
+ - **[SECURITY.md](SECURITY.md)** — the security model: trust boundaries, what runs automatically, supply-chain guarantees, and how to report a vulnerability.
147
167
 
148
168
  ## Evaluation
149
169
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.3.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 Install skills into a Claude config dir (passes flags to install.mjs)
21
- uninstall Remove the library (passes flags to uninstall.mjs)
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/commands/role.md CHANGED
@@ -8,39 +8,61 @@ disable-model-invocation: true
8
8
 
9
9
  The user is managing the active swe-workflow skill role. The requested role argument is: `$ARGUMENTS`
10
10
 
11
- **Validate the argument before running anything.** It must be empty or match
12
- `^[a-z0-9_-]{1,32}$` (role keys are short kebab-case names). If it does not match —
13
- any spaces, quotes, `$`, backticks, slashes, or other characters do NOT run the
14
- script below with it: reply that the role name is invalid and show the available
15
- roles by running only `node "@@RESOLVE@@" roles` (with `ROLES_JSON="@@ROLES@@"`
16
- exported).
17
-
18
- If the argument is valid, run this script exactly once via Bash, replacing
19
- `__ROLE__` with the validated argument (or with nothing when no argument was
20
- given), then report the result to the user concisely (the new active role, and
21
- that the change hot-reloads so it applies to the next prompt):
11
+ **Validate the argument before running anything this is the security-critical
12
+ step.** It must be empty or match `^[a-z0-9_-]{1,32}$` (role keys are short
13
+ kebab-case names). If it contains ANYTHING else a space, quote, `$`, backtick,
14
+ slash, semicolon, or any other character the argument is invalid: do NOT run any
15
+ Bash command that contains the argument in any form (not in a variable, not as an
16
+ argument, not anywhere). Instead reply that the role name is invalid and list the
17
+ available roles by running only the fixed command `node "@@RESOLVE@@" roles` with
18
+ `ROLES_JSON="@@ROLES@@"` exported that command does not reference the argument at
19
+ all.
20
+
21
+ If the argument is valid (or empty), run the setup block once, then run the ONE
22
+ branch below that matches, substituting the validated role for `__ROLE__` **only
23
+ where shown** (always inside double quotes as the final argument of a `node`
24
+ call — never assigned to a shell variable). Then report the result concisely (the
25
+ new active role, and that the change hot-reloads so it applies to the next
26
+ prompt):
22
27
 
23
28
  ```bash
24
- ROLE="__ROLE__"
25
- case "$ROLE" in *[!a-zA-Z0-9_-]*) echo "invalid role name" >&2; exit 1;; esac
29
+ # --- setup (no argument appears here) ---
26
30
  RESOLVE="@@RESOLVE@@"; SKILLS="@@SKILLS@@"; SETTINGS="@@SETTINGS@@"
27
31
  ROLES="@@ROLES@@"; ACTIVE="@@ACTIVE_ROLE@@"
28
32
  export ROLES_JSON="$ROLES"
29
- if [ -z "$ROLE" ]; then
30
- echo "Active role: $(cat "$ACTIVE" 2>/dev/null || echo 'baseline (none)')"
31
- echo "Available roles:"; node "$RESOLVE" roles
32
- elif [ "$ROLE" = "all" ] || [ "$ROLE" = "none" ]; then
33
- node "$RESOLVE" apply "$SETTINGS" "$SKILLS" none && rm -f "$ACTIVE"
34
- echo "Reset to baseline only the pinned skills auto-trigger now."
35
- elif node "$RESOLVE" label "$ROLE" >/dev/null 2>&1; then
36
- node "$RESOLVE" apply "$SETTINGS" "$SKILLS" "$ROLE" && printf '%s\n' "$ROLE" > "$ACTIVE"
37
- echo "Active role set to '$ROLE' its skills now auto-trigger."
33
+ ```
34
+
35
+ Empty argument (no role given) — show current + available, no substitution:
36
+ ```bash
37
+ echo "Active role: $(cat "$ACTIVE" 2>/dev/null || echo 'baseline (none)')"
38
+ echo "Available roles:"; node "$RESOLVE" roles
39
+ ```
40
+
41
+ Argument is `all` or `none`reset to baseline:
42
+ ```bash
43
+ node "$RESOLVE" apply "$SETTINGS" "$SKILLS" none && rm -f "$ACTIVE"
44
+ echo "Reset to baseline — only the pinned skills auto-trigger now."
45
+ ```
46
+
47
+ Any other validated role — apply it. `resolve.mjs` rejects an unknown role with a
48
+ non-zero exit, so the `&&` short-circuits and the marker is only written for a role
49
+ that actually resolved:
50
+ ```bash
51
+ if node "$RESOLVE" apply "$SETTINGS" "$SKILLS" "__ROLE__" && printf '%s\n' "__ROLE__" > "$ACTIVE"; then
52
+ echo "Active role set — its skills now auto-trigger."
38
53
  else
39
- echo "Unknown role '$ROLE'. Available roles:"; node "$RESOLVE" roles
54
+ echo "Unknown role. Available roles:"; node "$RESOLVE" roles
40
55
  fi
41
56
  ```
42
57
 
43
58
  Notes:
44
59
  - `skillOverrides` and the skill listing hot-reload when `settings.local.json` changes, so the new auto-trigger set takes effect on the next prompt without a restart.
45
60
  - This command is for the full (all-skills) CLI install. Hard-subset (`--role`) installs and the per-role marketplace plugins don't need it.
46
- - Security: the script deliberately never embeds `$ARGUMENTS` directly (slash-command templates interpolate it as text, so a crafted argument would execute in the shell). The value reaches the script only via the validated `__ROLE__` transfer above; the in-script `case` guard is defense-in-depth.
61
+ - Security: `$ARGUMENTS` is never embedded directly (slash-command templates
62
+ interpolate it as text, so a crafted argument would execute in the shell). The
63
+ **primary control is the model-side regex validation above** — do it before
64
+ emitting any Bash. As a deterministic backstop, the validated value reaches the
65
+ shell only as the final quoted argv of a `node "$RESOLVE"` call, where
66
+ `resolve.mjs` (`roleOrDie`) rejects any string that is not a known role before
67
+ it is used — the value is never placed in a bare `ROLE=...` assignment. See
68
+ `SECURITY.md` for the residual (quote-escape) risk and why it is accepted.
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
@@ -52,6 +52,9 @@ Options:
52
52
  -p, --prune After installing the selected set, remove previously-installed
53
53
  library skills that are NOT in the new selection (never touches
54
54
  your own custom skills). Use to narrow a prior all-skills install.
55
+ -f, --force Overwrite a same-named skill directory that this installer did
56
+ not create (by default such a collision is skipped, so your own
57
+ custom skill with a library name is never clobbered).
55
58
  -k, --hook (default) Install the SessionStart hook that re-asserts the
56
59
  name-only baseline each session + injects the router nudge
57
60
  (prints the settings snippet; never edits settings)
@@ -106,6 +109,7 @@ function toPosix(p) {
106
109
  let global = false;
107
110
  let hook = true;
108
111
  let prune = false;
112
+ let force = false;
109
113
  let configDir = "";
110
114
  let role = "";
111
115
  const selected = [];
@@ -125,6 +129,7 @@ for (let i = 0; i < argv.length; i++) {
125
129
  if (role === undefined) fatal("--role requires a role name");
126
130
  } else if (a.startsWith("--role=")) role = a.slice("--role=".length);
127
131
  else if (a === "-p" || a === "--prune") prune = true;
132
+ else if (a === "-f" || a === "--force") force = true;
128
133
  else if (a === "-k" || a === "--hook") hook = true;
129
134
  else if (a === "--no-hook") hook = false;
130
135
  else if (a === "-l" || a === "--list") {
@@ -192,9 +197,46 @@ if (skillSet.length === 0) {
192
197
  skillSet = role ? resolvedSkills(rolesData, role) : listSkillDirs();
193
198
  }
194
199
 
200
+ // ---- provenance manifest ---------------------------------------------------
201
+ // Records which skill directories THIS installer created, so uninstall/--prune
202
+ // only ever remove our own skills and a re-install never clobbers a user's custom
203
+ // skill that happens to share a library name. See docs SECURITY.md (LOW-003).
204
+ const MANIFEST = join(dest, ".swe-workflow-manifest.json");
205
+
206
+ function readManifestSkills() {
207
+ if (existsSync(MANIFEST)) {
208
+ try {
209
+ const m = JSON.parse(readFileSync(MANIFEST, "utf-8"));
210
+ if (Array.isArray(m.skills)) return new Set(m.skills);
211
+ } catch {
212
+ /* unreadable manifest -> treat as absent */
213
+ }
214
+ }
215
+ return null; // null = no manifest present
216
+ }
217
+
218
+ const priorManifest = readManifestSkills();
219
+ // A prior swe-workflow install (any version) leaves these machinery markers even
220
+ // before manifests existed. If neither a manifest nor a marker is present, the dest
221
+ // is not one we've installed into, so a same-named dir there must be the user's.
222
+ const priorInstall =
223
+ priorManifest !== null ||
224
+ existsSync(join(dest, ".roles.json")) ||
225
+ existsSync(join(claudeDir, "hooks", "resolve.mjs"));
226
+
227
+ // Do we own the skill dir currently at dest? Manifest is authoritative when present;
228
+ // otherwise fall back to "is this dest a prior swe install at all?" so pre-manifest
229
+ // upgrades still overwrite our own skills rather than skipping them.
230
+ function installerOwns(skill) {
231
+ if (priorManifest !== null) return priorManifest.has(skill);
232
+ return priorInstall;
233
+ }
234
+
195
235
  // ---- copy skills -----------------------------------------------------------
196
236
 
197
237
  let errors = 0;
238
+ const installedNow = [];
239
+ const skippedCollisions = [];
198
240
  for (const skill of skillSet) {
199
241
  const src = join(SKILLS_DIR, skill);
200
242
  if (!isDir(src)) {
@@ -202,24 +244,59 @@ for (const skill of skillSet) {
202
244
  errors++;
203
245
  continue;
204
246
  }
247
+ const destPath = join(dest, skill);
248
+ // Never clobber a same-named directory we didn't create unless --force.
249
+ if (isDir(destPath) && !force && !installerOwns(skill)) {
250
+ warn(
251
+ `skipping '${skill}': a skill of that name already exists and was not installed ` +
252
+ `by swe-workflow-skills. Use --force to overwrite it.`,
253
+ );
254
+ skippedCollisions.push(skill);
255
+ continue;
256
+ }
205
257
  // Clean copy: drop any prior version first so files removed upstream don't linger.
206
- rmSync(join(dest, skill), { recursive: true, force: true });
207
- cpSync(src, join(dest, skill), { recursive: true });
208
- log(`Installed: ${skill} -> ${join(dest, skill)}`);
258
+ rmSync(destPath, { recursive: true, force: true });
259
+ cpSync(src, destPath, { recursive: true });
260
+ installedNow.push(skill);
261
+ log(`Installed: ${skill} -> ${destPath}`);
209
262
  }
210
263
 
211
264
  // --prune: narrow a prior install to the current selection. Only ever removes skills
212
- // that exist in our source tree (so the user's own custom skills are never touched).
265
+ // that exist in our source tree AND that this installer created (so the user's own
266
+ // custom skills — including any that share a library name — are never touched).
267
+ const pruned = new Set();
213
268
  if (prune) {
214
269
  const keep = new Set(skillSet);
215
270
  for (const s of listSkillDirs()) {
216
- if (!keep.has(s) && isDir(join(dest, s))) {
271
+ if (!keep.has(s) && isDir(join(dest, s)) && installerOwns(s)) {
217
272
  rmSync(join(dest, s), { recursive: true, force: true });
273
+ pruned.add(s);
218
274
  log(`Pruned: ${s} (not in selection)`);
219
275
  }
220
276
  }
221
277
  }
222
278
 
279
+ // Rewrite the provenance manifest: every library skill we own that is still present.
280
+ // = (what we owned before) ∪ (installed this run) − (pruned this run), intersected
281
+ // with library skill dirs actually on disk. Skipped collisions never enter it.
282
+ const libNames = new Set(listSkillDirs());
283
+ const ownedSkills = new Set([...(priorManifest || []), ...installedNow]);
284
+ const manifestSkills = [...ownedSkills]
285
+ .filter((s) => !pruned.has(s) && libNames.has(s) && isDir(join(dest, s)))
286
+ .sort();
287
+ if (installedNow.length > 0 || priorManifest !== null || prune) {
288
+ let manifestVersion = "";
289
+ try {
290
+ manifestVersion = readFileSync(join(REPO_ROOT, "VERSION"), "utf-8").trim();
291
+ } catch {
292
+ /* VERSION is optional metadata in the manifest */
293
+ }
294
+ writeFileSync(
295
+ MANIFEST,
296
+ JSON.stringify({ installer: "swe-workflow-skills", version: manifestVersion, skills: manifestSkills }, null, 2) + "\n",
297
+ );
298
+ }
299
+
223
300
  // ---- orchestrator machinery (only when skill-router is in the set) ---------
224
301
 
225
302
  const hasRouter = skillSet.includes("skill-router");
@@ -292,17 +369,34 @@ if (hook) {
292
369
  hookPath = join(hookDestDir, "session-start.mjs");
293
370
  log(`Installed hook script -> ${hookPath}`);
294
371
  log("");
295
- log("The name-only baseline is already applied. To have it re-asserted every");
296
- log("session (and the router nudge injected), merge this into");
297
- log(`${join(claudeDir, "settings.json")} (the installer does NOT edit settings for you):`);
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):");
298
382
  log("");
299
383
  log(hookSnippet(hookPath));
300
384
  log("");
301
- log("Start a new session and run /doctor to confirm the hook is registered.");
302
- log("(Prefer no hook? Re-run with --no-hook; the baseline still applies.)");
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.)");
303
388
  }
304
389
  }
305
390
 
391
+ // Per-skill collision warnings scroll away during a full install; restate them once
392
+ // at the end so an unowned same-named skill that was left in place is not missed.
393
+ if (skippedCollisions.length > 0) {
394
+ warn(
395
+ `left ${skippedCollisions.length} existing skill(s) untouched (not installed by ` +
396
+ `swe-workflow-skills): ${skippedCollisions.join(", ")}. Re-run with --force to overwrite.`,
397
+ );
398
+ }
399
+
306
400
  if (errors !== 0) process.exit(1);
307
401
 
308
402
  function copyIfExists(src, destPath) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "swe-workflow-skills",
3
- "version": "0.3.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",
@@ -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
@@ -12,7 +12,7 @@
12
12
  // node uninstall.mjs --global # remove from the user config dir
13
13
  // node uninstall.mjs --dir DIR --dry-run
14
14
 
15
- import { existsSync, statSync, readdirSync, rmSync, rmdirSync } from "node:fs";
15
+ import { existsSync, statSync, readdirSync, readFileSync, rmSync, rmdirSync } from "node:fs";
16
16
  import { dirname, join, resolve } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { homedir } from "node:os";
@@ -96,16 +96,39 @@ if (!isDir(claudeDir)) {
96
96
 
97
97
  const settingsLocal = join(claudeDir, "settings.local.json");
98
98
 
99
- // Build the removal list: only library skills present on disk, plus the machinery.
100
- const libSkills = readdirSync(SKILLS_DIR)
101
- .filter((s) => isDir(join(SKILLS_DIR, s)) && isDir(join(dest, s)))
102
- .sort();
99
+ // Build the removal list: only library skills THIS installer created, plus the
100
+ // machinery. The provenance manifest is authoritative when present, so a user's own
101
+ // custom skill that shares a library name is never removed. Pre-manifest installs
102
+ // have no manifest — fall back to matching source-tree skill names (the historical
103
+ // behavior) so they can still be uninstalled.
104
+ const manifestPath = join(dest, ".swe-workflow-manifest.json");
105
+ let manifestSkills = null;
106
+ if (existsSync(manifestPath)) {
107
+ try {
108
+ const m = JSON.parse(readFileSync(manifestPath, "utf-8"));
109
+ if (Array.isArray(m.skills)) manifestSkills = m.skills;
110
+ } catch {
111
+ /* unreadable manifest -> fall back to name-match below */
112
+ }
113
+ }
114
+
115
+ let libSkills;
116
+ if (manifestSkills !== null) {
117
+ const srcSkills = new Set(readdirSync(SKILLS_DIR).filter((s) => isDir(join(SKILLS_DIR, s))));
118
+ libSkills = manifestSkills.filter((s) => srcSkills.has(s) && isDir(join(dest, s))).sort();
119
+ } else {
120
+ libSkills = readdirSync(SKILLS_DIR)
121
+ .filter((s) => isDir(join(SKILLS_DIR, s)) && isDir(join(dest, s)))
122
+ .sort();
123
+ }
103
124
 
104
125
  const targets = [...libSkills.map((s) => join(dest, s))];
105
126
  for (const f of [
106
127
  join(dest, ".roles.json"),
107
128
  join(dest, ".catalog.json"),
108
129
  join(dest, ".active-role"),
130
+ join(dest, ".disabled-skills"),
131
+ join(dest, ".swe-workflow-manifest.json"),
109
132
  join(claudeDir, "hooks", "resolve.mjs"),
110
133
  join(claudeDir, "hooks", "session-start.mjs"),
111
134
  join(claudeDir, "commands", "role.md"),