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 +23 -3
- package/VERSION +1 -1
- package/bin/cli.mjs +18 -2
- package/commands/role.md +46 -24
- package/disable.mjs +207 -0
- package/install.mjs +104 -10
- package/package.json +2 -1
- package/scripts/resolve.mjs +77 -2
- package/uninstall.mjs +28 -5
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
|
|
|
@@ -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.
|
|
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/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
|
|
12
|
-
`^[a-z0-9_-]{1,32}$` (role keys are short
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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:
|
|
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(
|
|
207
|
-
cpSync(src,
|
|
208
|
-
|
|
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
|
|
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("
|
|
296
|
-
log("
|
|
297
|
-
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):");
|
|
298
382
|
log("");
|
|
299
383
|
log(hookSnippet(hookPath));
|
|
300
384
|
log("");
|
|
301
|
-
log("
|
|
302
|
-
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.)");
|
|
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
|
+
"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
|
@@ -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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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"),
|