skillrepo 3.2.0 → 4.1.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.
Files changed (53) hide show
  1. package/README.md +137 -27
  2. package/bin/skillrepo.mjs +5 -5
  3. package/package.json +1 -1
  4. package/src/commands/add.mjs +21 -6
  5. package/src/commands/get.mjs +20 -4
  6. package/src/commands/init-cohort-hooks.mjs +127 -0
  7. package/src/commands/init-session-sync.mjs +1 -1
  8. package/src/commands/init.mjs +480 -117
  9. package/src/commands/list.mjs +1 -1
  10. package/src/commands/remove.mjs +10 -2
  11. package/src/commands/uninstall.mjs +13 -2
  12. package/src/commands/update.mjs +112 -19
  13. package/src/lib/agent-hook-merge.mjs +203 -0
  14. package/src/lib/agent-registry.mjs +399 -0
  15. package/src/lib/artifact-registry.mjs +111 -2
  16. package/src/lib/cli-config.mjs +146 -44
  17. package/src/lib/detect-agents.mjs +112 -0
  18. package/src/lib/file-write.mjs +162 -77
  19. package/src/lib/fs-utils.mjs +16 -1
  20. package/src/lib/mcp-merge.mjs +17 -36
  21. package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
  22. package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
  23. package/src/lib/mergers/gitignore.mjs +55 -28
  24. package/src/lib/paths.mjs +27 -25
  25. package/src/lib/prompt-multiselect.mjs +324 -0
  26. package/src/lib/removers/agent-hooks.mjs +83 -0
  27. package/src/lib/sync.mjs +18 -19
  28. package/src/test/commands/add.test.mjs +18 -3
  29. package/src/test/commands/init-picker.test.mjs +144 -0
  30. package/src/test/commands/init.test.mjs +508 -41
  31. package/src/test/commands/remove.test.mjs +4 -1
  32. package/src/test/commands/update.test.mjs +148 -3
  33. package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
  34. package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
  35. package/src/test/e2e/cli-commands.test.mjs +39 -13
  36. package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
  37. package/src/test/integration/file-write.integration.test.mjs +31 -10
  38. package/src/test/lib/agent-hook-merge.test.mjs +172 -0
  39. package/src/test/lib/agent-registry.test.mjs +215 -0
  40. package/src/test/lib/artifact-registry.test.mjs +39 -0
  41. package/src/test/lib/cli-config.test.mjs +222 -38
  42. package/src/test/lib/detect-agents.test.mjs +336 -0
  43. package/src/test/lib/file-write-placement.test.mjs +264 -0
  44. package/src/test/lib/file-write.test.mjs +231 -30
  45. package/src/test/lib/mcp-merge.test.mjs +23 -15
  46. package/src/test/lib/paths.test.mjs +53 -17
  47. package/src/test/lib/prompt-multiselect.test.mjs +448 -0
  48. package/src/test/lib/sync.test.mjs +157 -0
  49. package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
  50. package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
  51. package/src/test/removers/agent-hooks.test.mjs +206 -0
  52. package/src/lib/detect-ides.mjs +0 -44
  53. package/src/test/detect-ides.test.mjs +0 -65
@@ -2,7 +2,7 @@
2
2
  * Shared credential + flag resolution for command modules.
3
3
  *
4
4
  * Every command needs to:
5
- * 1. Resolve `--key`/`--url`/`--ide`/`--global`/`--json` flags
5
+ * 1. Resolve `--key`/`--url`/`--agent`/`--global`/`--json` flags
6
6
  * 2. Fall back to ~/.claude/skillrepo/config.json
7
7
  * 3. Fall back to SKILLREPO_ACCESS_KEY / SKILLREPO_URL env vars
8
8
  * 4. Hard-error with an actionable hint pointing at `init` if no
@@ -20,17 +20,28 @@
20
20
  import { existsSync, readFileSync } from "node:fs";
21
21
 
22
22
  import { globalConfigPath } from "./paths.mjs";
23
+ import {
24
+ AGENT_REGISTRY,
25
+ AGENTS_COHORT_KEYS,
26
+ getAgentByAlias,
27
+ } from "./agent-registry.mjs";
23
28
  import { authError, validationError } from "./errors.mjs";
24
29
 
25
- const VALID_VENDORS = new Set(["claudeCode", "cursor", "windsurf", "vscode"]);
26
- const VENDOR_ALIASES = { claude: "claudeCode" };
30
+ const ALL_VENDOR_KEYS = AGENT_REGISTRY.map((entry) => entry.key);
31
+
32
+ // Sentinel returned by parseAgentList for `--agent none`. It is the
33
+ // empty array literally — every consumer reads `flags.vendors.length`
34
+ // to detect "no placement writes." Frozen so a downstream mutation
35
+ // (e.g. `.push`) cannot turn one user's `none` into another's surprise.
36
+ const NO_VENDORS = Object.freeze([]);
27
37
 
28
38
  /**
29
39
  * @typedef {Object} ResolvedFlags
30
40
  * @property {string} serverUrl
31
41
  * @property {string} apiKey
32
42
  * @property {boolean} global
33
- * @property {string[]|null} vendors - null = use the default
43
+ * @property {string[]|null} vendors - null = use the default;
44
+ * empty array = `--agent none` (caller skips placement)
34
45
  * @property {boolean} json
35
46
  */
36
47
 
@@ -78,8 +89,22 @@ export function resolveFlags(argv, opts = {}) {
78
89
 
79
90
  if (arg === "--global") {
80
91
  flagState.global = true;
81
- } else if (arg === "--ide" && argv[i + 1] !== undefined) {
82
- flagState.vendors = parseVendorList(argv[++i]);
92
+ } else if (arg === "--agent") {
93
+ if (argv[i + 1] === undefined) {
94
+ throw validationError(
95
+ "Missing value for --agent. Pass a comma-separated list (e.g., --agent claude,agents).",
96
+ );
97
+ }
98
+ flagState.vendors = parseAgentList(argv[++i]);
99
+ } else if (arg === "--ide") {
100
+ throw validationError(
101
+ "--ide was renamed to --agent in v3.0.0.",
102
+ {
103
+ hint:
104
+ "Replace --ide with --agent (e.g., --agent claude). " +
105
+ "Run the command with --help for valid options.",
106
+ },
107
+ );
83
108
  } else if (arg === "--json") {
84
109
  flagState.json = true;
85
110
  } else if ((arg === "--key" || arg === "-k") && argv[i + 1] !== undefined) {
@@ -172,62 +197,139 @@ export function resolveFlags(argv, opts = {}) {
172
197
  }
173
198
 
174
199
  /**
175
- * Compute the vendor list to pass to file-write / sync. Defaults to
176
- * `["claudeCode"]` when neither `--ide` nor `--global` is provided —
177
- * the v3.0.0 CLI deliberately does NOT silently fall back to
178
- * `[claudeCode, cursor]` like v2.0.0 did. The user opts in.
200
+ * Compute the vendor list to pass to file-write / sync.
201
+ *
202
+ * Resolution order:
203
+ * 1. Explicit `--agent` (including the `--agent none` sentinel `[]`)
204
+ * always wins. Both flags propagate together, so `--global
205
+ * --agent windsurf` correctly resolves to `["windsurf"]` and
206
+ * placementTargetsFor maps that to `windsurfGlobal`.
207
+ * 2. No `--agent` defaults to `["claudeCode"]`, regardless of
208
+ * `--global`. The v3.0.0 CLI deliberately does NOT silently
209
+ * fall back to `[claudeCode, cursor]` like v2.0.0 did.
210
+ *
211
+ * The empty-array `--agent none` sentinel is returned verbatim —
212
+ * callers detect "no placement" via `vendors.length === 0`. The
213
+ * default-fallback branch uses an explicit null/undefined check so
214
+ * the empty array survives.
179
215
  */
180
216
  export function effectiveVendors(flags) {
181
- if (flags.global) return undefined; // global mode ignores vendors
182
- if (flags.vendors) return flags.vendors;
183
- return ["claudeCode"];
217
+ if (flags.vendors === null || flags.vendors === undefined) {
218
+ return ["claudeCode"];
219
+ }
220
+ return flags.vendors;
221
+ }
222
+
223
+ /**
224
+ * Reject `--agent none` for commands that have no meaningful behavior
225
+ * without a placement target. `init` and `update` accept `--agent none`
226
+ * (init still writes config + gitignore; update is a no-op). Every
227
+ * other write/read-write command (`add`, `get`, `remove`, `uninstall`)
228
+ * exists to put or remove specific skill files on disk — calling them
229
+ * with no targets is a user error, not a degenerate-but-valid case.
230
+ *
231
+ * Pass the resolved `vendors` array (the output of `effectiveVendors`).
232
+ * `undefined` (the `--global` case) is allowed because `--global`
233
+ * implies the Claude Code personal directory; only an empty array is
234
+ * rejected.
235
+ *
236
+ * @param {string[] | undefined} vendors
237
+ * @param {string} commandName - For the error message.
238
+ */
239
+ export function requireVendorTargets(vendors, commandName) {
240
+ if (Array.isArray(vendors) && vendors.length === 0) {
241
+ throw validationError(
242
+ `--agent none has no effect on \`skillrepo ${commandName}\` ` +
243
+ `because the command writes or deletes specific skill files.`,
244
+ {
245
+ hint:
246
+ "Use --agent claude or --agent agents (or a specific vendor) to " +
247
+ "target a placement, or drop --agent to use the default.",
248
+ },
249
+ );
250
+ }
184
251
  }
185
252
 
186
253
  // ── Internals ──────────────────────────────────────────────────────────
187
254
 
188
- function parseVendorList(raw) {
255
+ /**
256
+ * Parse the raw `--agent <list>` argument into a deduplicated array
257
+ * of canonical vendor keys, OR the empty array sentinel for `none`.
258
+ *
259
+ * Token semantics (each comma-separated token):
260
+ *
261
+ * - `none` → empty array (no placement writes). Must be the only
262
+ * token — mixing with any other token is rejected.
263
+ * - `claude` → `["claudeCode"]` (the Claude Code target shortcut).
264
+ * - `agents` → every cohort vendor whose project writes land in
265
+ * `.agents/skills/`, derived from the registry.
266
+ * - canonical key (e.g., `claudeCode`, `cursor`) → that key.
267
+ * - alias declared on a registry entry (e.g., `claude-code`,
268
+ * `vscode`) → its canonical key.
269
+ * - anything else → validation error.
270
+ *
271
+ * `all` is NOT a valid token — use `claude,agents` for full coverage.
272
+ */
273
+ function parseAgentList(raw) {
189
274
  if (typeof raw !== "string") {
190
- throw validationError(`--ide expects a comma-separated list, got: ${raw}`);
275
+ throw validationError(`--agent expects a comma-separated list, got: ${raw}`);
191
276
  }
192
277
 
193
- // First pass: collect tokens, normalize aliases, detect `all`.
194
278
  const pieces = raw.split(",").map((p) => p.trim()).filter(Boolean);
195
- const normalized = pieces.map((v) => VENDOR_ALIASES[v] ?? v);
196
- const hasAll = normalized.includes("all");
197
- const otherVendors = normalized.filter((v) => v !== "all");
198
-
199
- // Defense against confusing input: `--ide cursor,all` is ambiguous
200
- // — the user might think it means "cursor first, then everything"
201
- // or "all minus duplicates". Both interpretations are wrong because
202
- // `all` is the full set. Reject the combination so the user is
203
- // forced to pick one.
204
- if (hasAll && otherVendors.length > 0) {
205
- throw validationError(
206
- `--ide cannot mix "all" with other vendors: "${raw}"`,
207
- { hint: "Use --ide all OR --ide claude,cursor,... — not both." },
208
- );
279
+ if (pieces.length === 0) {
280
+ throw validationError("--agent list is empty.", {
281
+ hint: "Pass one or more of: claude, agents, none, or a vendor name.",
282
+ });
209
283
  }
210
284
 
211
- if (hasAll) {
212
- return ["claudeCode", "cursor", "windsurf", "vscode"];
285
+ // `none` is mutually exclusive with every other token. Mixing
286
+ // `--agent claude,none` is ambiguous — does the user want Claude
287
+ // or no placement? Reject so they pick one.
288
+ if (pieces.includes("none")) {
289
+ if (pieces.length > 1) {
290
+ throw validationError(
291
+ `--agent cannot mix "none" with other tokens: "${raw}"`,
292
+ { hint: "Pass --agent none alone, or list specific targets without none." },
293
+ );
294
+ }
295
+ return NO_VENDORS;
213
296
  }
214
297
 
215
- if (normalized.length === 0) {
216
- throw validationError("--ide list is empty.");
298
+ const expanded = [];
299
+ for (const token of pieces) {
300
+ if (token === "claude") {
301
+ expanded.push("claudeCode");
302
+ continue;
303
+ }
304
+ if (token === "agents") {
305
+ for (const key of AGENTS_COHORT_KEYS) {
306
+ expanded.push(key);
307
+ }
308
+ continue;
309
+ }
310
+ const canonical = getAgentByAlias(token);
311
+ if (canonical !== null) {
312
+ expanded.push(canonical);
313
+ continue;
314
+ }
315
+ throw validationError(`Unknown --agent target: "${token}"`, {
316
+ hint:
317
+ "Use one of: claude, agents, none, or a canonical vendor key " +
318
+ `(${ALL_VENDOR_KEYS.join(", ")}).`,
319
+ });
217
320
  }
218
321
 
219
- // Validate each vendor token
220
- for (const resolved of normalized) {
221
- if (!VALID_VENDORS.has(resolved)) {
222
- // Find the original (un-aliased) form for the error message
223
- const original = pieces[normalized.indexOf(resolved)];
224
- throw validationError(`Unknown --ide vendor: "${original}"`, {
225
- hint: "Use claudeCode (or 'claude'), cursor, windsurf, vscode, or 'all'.",
226
- });
322
+ // Dedupe while preserving first-seen order — matters for tests and
323
+ // for downstream consumers that iterate the array deterministically.
324
+ const seen = new Set();
325
+ const result = [];
326
+ for (const key of expanded) {
327
+ if (!seen.has(key)) {
328
+ seen.add(key);
329
+ result.push(key);
227
330
  }
228
331
  }
229
-
230
- return normalized;
332
+ return result;
231
333
  }
232
334
 
233
335
  function readGlobalConfig() {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Multi-signal agent detection (#1236, Phase 3 of #876).
3
+ *
4
+ * Replaces the single-signal `detect-ides.mjs` module. Detection is
5
+ * registry-driven: every probe is described as a `DetectionSignal` on
6
+ * the corresponding `AGENT_REGISTRY` entry, and this module just
7
+ * iterates the registry and runs the platform-correct probe for each
8
+ * signal type. Adding a new signal is a registry edit, no change here.
9
+ *
10
+ * Three signal types:
11
+ *
12
+ * - `env` — `process.env[value]` is truthy. Indicates an active
13
+ * agent session (the agent itself sets the var in the
14
+ * shells it spawns).
15
+ * - `home` — `~/<value>` exists on disk. Indicates the agent has
16
+ * been installed for this user (most agents create the
17
+ * directory on first run).
18
+ * - `project` — `<cwd>/<value>` exists on disk. Indicates the agent
19
+ * is configured for this repo (dotdir / dotfile).
20
+ *
21
+ * For each agent, `detected` is the OR of all signal probes. The first
22
+ * signal that fires (priority order: env > home > project) becomes the
23
+ * human-readable `reason`. Reasons are formatted to match the picker UX:
24
+ *
25
+ * - env signal: `"CLAUDECODE=1"`
26
+ * - home signal: `"~/.claude/"` or `"~/.codeium/windsurf/"`
27
+ * - project signal: `".claude/"` or `".github/skills/"`
28
+ * - none: `null` (the picker translates this to
29
+ * `"(no signal — opt in if you use one)"`)
30
+ *
31
+ * Cross-platform: paths use `node:path.join(homedir(), …)` and
32
+ * `join(process.cwd(), …)`. On Windows, `homedir()` reads
33
+ * `USERPROFILE`; the test sandbox helper sets both HOME and USERPROFILE
34
+ * so probes are sandbox-isolated on every platform.
35
+ */
36
+
37
+ import { existsSync } from "node:fs";
38
+ import { homedir } from "node:os";
39
+ import { join } from "node:path";
40
+
41
+ import { AGENT_REGISTRY } from "./agent-registry.mjs";
42
+
43
+ /**
44
+ * @typedef {Object} AgentDetection
45
+ * @property {string} key - Canonical agent key.
46
+ * @property {string} displayName - Brand name (used in picker hints).
47
+ * @property {boolean} detected - True if any signal fired.
48
+ * @property {string|null} reason - Short human-readable label of the
49
+ * first signal that fired, or `null` when nothing fired.
50
+ */
51
+
52
+ /**
53
+ * Probe one detection signal against the live process state and disk.
54
+ *
55
+ * Pure helper. Returns the formatted reason string when the signal
56
+ * fires, or `null` when it does not.
57
+ *
58
+ * @param {import("./agent-registry.mjs").DetectionSignal} signal
59
+ * @returns {string | null}
60
+ */
61
+ function probeSignal(signal) {
62
+ if (signal.type === "env") {
63
+ const raw = process.env[signal.value];
64
+ if (raw === undefined || raw === "") return null;
65
+ // Env vars in shell environments are always strings. Truthy
66
+ // values include "1", "true", "yes", etc — Cline's documented
67
+ // value is the literal string "true". We accept any non-empty
68
+ // value rather than a stricter truthy-string match because the
69
+ // signal's purpose is "the agent set this var on me" and any
70
+ // value the agent chose to set qualifies.
71
+ return `${signal.value}=${raw}`;
72
+ }
73
+ if (signal.type === "home") {
74
+ const path = join(homedir(), signal.value);
75
+ if (!existsSync(path)) return null;
76
+ return `~/${signal.value}/`;
77
+ }
78
+ if (signal.type === "project") {
79
+ const path = join(process.cwd(), signal.value);
80
+ if (!existsSync(path)) return null;
81
+ return `${signal.value}/`;
82
+ }
83
+ // Unknown type — defensive guard. Callers should never see this
84
+ // because the registry's frozen entries are typed.
85
+ return null;
86
+ }
87
+
88
+ /**
89
+ * Detect every agent in the registry against the current process and
90
+ * working directory. Returns one `AgentDetection` per registry entry,
91
+ * in registry order (Claude Code first, then the cohort).
92
+ *
93
+ * @returns {AgentDetection[]}
94
+ */
95
+ export function detectAgents() {
96
+ return AGENT_REGISTRY.map((entry) => {
97
+ let reason = null;
98
+ for (const signal of entry.detectionSignals) {
99
+ const fired = probeSignal(signal);
100
+ if (fired !== null) {
101
+ reason = fired;
102
+ break;
103
+ }
104
+ }
105
+ return {
106
+ key: entry.key,
107
+ displayName: entry.displayName,
108
+ detected: reason !== null,
109
+ reason,
110
+ };
111
+ });
112
+ }