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 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
 
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.4.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/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("The name-only baseline is already applied. To have it re-asserted every");
373
- log("session (and the router nudge injected), merge this into");
374
- 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):");
375
382
  log("");
376
383
  log(hookSnippet(hookPath));
377
384
  log("");
378
- log("Start a new session and run /doctor to confirm the hook is registered.");
379
- 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.)");
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.4.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
@@ -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"),