swe-workflow-skills 0.3.0 → 0.4.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 +1 -0
- package/VERSION +1 -1
- package/commands/role.md +46 -24
- package/install.mjs +91 -5
- package/package.json +1 -1
- package/uninstall.mjs +27 -5
package/README.md
CHANGED
|
@@ -144,6 +144,7 @@ review) live in the `skill-router` skill and **[ROLES.md](docs/ROLES.md)**.
|
|
|
144
144
|
- **[EVALS.md](docs/EVALS.md)** — how the skills are tested (RED/GREEN, pressure tests, CI gate).
|
|
145
145
|
- **[AUTHORING.md](docs/AUTHORING.md)** — write or modify a skill (descriptions, budget, progressive disclosure, evals).
|
|
146
146
|
- **[RELEASING.md](docs/RELEASING.md)** — versioning policy and how to cut a release. Changes are tracked in **[CHANGELOG.md](CHANGELOG.md)**.
|
|
147
|
+
- **[SECURITY.md](SECURITY.md)** — the security model: trust boundaries, what runs automatically, supply-chain guarantees, and how to report a vulnerability.
|
|
147
148
|
|
|
148
149
|
## Evaluation
|
|
149
150
|
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.4.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/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");
|
|
@@ -303,6 +380,15 @@ if (hook) {
|
|
|
303
380
|
}
|
|
304
381
|
}
|
|
305
382
|
|
|
383
|
+
// Per-skill collision warnings scroll away during a full install; restate them once
|
|
384
|
+
// at the end so an unowned same-named skill that was left in place is not missed.
|
|
385
|
+
if (skippedCollisions.length > 0) {
|
|
386
|
+
warn(
|
|
387
|
+
`left ${skippedCollisions.length} existing skill(s) untouched (not installed by ` +
|
|
388
|
+
`swe-workflow-skills): ${skippedCollisions.join(", ")}. Re-run with --force to overwrite.`,
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
306
392
|
if (errors !== 0) process.exit(1);
|
|
307
393
|
|
|
308
394
|
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.4.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": {
|
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,38 @@ 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, ".swe-workflow-manifest.json"),
|
|
109
131
|
join(claudeDir, "hooks", "resolve.mjs"),
|
|
110
132
|
join(claudeDir, "hooks", "session-start.mjs"),
|
|
111
133
|
join(claudeDir, "commands", "role.md"),
|