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 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.3.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.** 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/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");
@@ -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.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 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, ".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"),