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
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Multi-select prompt primitive (#1236, Phase 3 of #876).
3
+ *
4
+ * The two-row picker `init` step 4 uses to ask the user which targets
5
+ * to configure (Claude Code / Other agents / None). Designed as a
6
+ * general primitive so future commands can reuse it without copying
7
+ * keypress-handling code.
8
+ *
9
+ * Two modes:
10
+ *
11
+ * - **TTY mode** (`process.stdout.isTTY`): renders a checkbox list
12
+ * with a cursor highlight. Up/down arrows move the cursor, space
13
+ * toggles the current item, `a` toggles all, enter confirms.
14
+ * Uses `node:readline.emitKeypressEvents` + raw mode — the same
15
+ * approach the existing `promptSecret` helper uses, so the
16
+ * dependency surface stays node-builtins-only.
17
+ *
18
+ * - **Non-TTY mode** (pipes, `--json` consumers, CI): prints the
19
+ * numbered list with pre-checked markers, then reads a single line
20
+ * of comma-separated keys from stdin. Empty line → returns the
21
+ * pre-checked items.
22
+ *
23
+ * Cross-platform notes:
24
+ *
25
+ * - Raw-mode keypress events are cross-platform on Node ≥ 18. CSI
26
+ * escape sequences for arrow keys ("\x1b[A" etc.) are emitted
27
+ * identically by the Windows console host and ConPTY in Windows
28
+ * Terminal — verified against Node's `readline` source. We rely
29
+ * on the parsed `key.name` ("up" / "down") rather than raw bytes
30
+ * so any platform variance in the underlying bytes is absorbed
31
+ * by Node's keypress parser.
32
+ * - The renderer uses `\n` as line separator (not `\r\n`); Node's
33
+ * line-buffered stdout converts to platform line endings on
34
+ * Windows when stdout is a console.
35
+ * - ANSI cursor-control codes ("\x1b[<N>A" / "\x1b[2K") are honored
36
+ * by Windows Terminal, ConPTY, VS Code's integrated terminal,
37
+ * and modern PowerShell hosts. Legacy cmd.exe without ANSI
38
+ * interpretation will see the codes as visible noise — Node's
39
+ * `process.stdout.isTTY` is true there too, so those users get a
40
+ * functional but cosmetically degraded picker. Acceptable
41
+ * tradeoff: the only fully-clean fallback would be redrawing
42
+ * without cursor positioning, which adds complexity for a
43
+ * vanishing population.
44
+ */
45
+
46
+ import { emitKeypressEvents } from "node:readline";
47
+
48
+ /**
49
+ * @typedef {Object} MultiSelectItem
50
+ * @property {string} key - Returned in the result.
51
+ * @property {string} label - Primary user-visible text.
52
+ * @property {string} [hint] - Optional dim text after the label
53
+ * (e.g. detection reason, target path).
54
+ * @property {boolean} preChecked - Initial checked state.
55
+ */
56
+
57
+ /**
58
+ * @typedef {Object} MultiSelectIo
59
+ * @property {NodeJS.ReadableStream} [stdin=process.stdin]
60
+ * @property {NodeJS.WritableStream} [stdout=process.stdout]
61
+ * @property {boolean} [forceTty] - Override the stdout TTY check
62
+ * (test-only). When undefined, falls back to
63
+ * `stdout.isTTY`.
64
+ */
65
+
66
+ const ANSI_CLEAR_LINE = "\x1b[2K";
67
+ const ANSI_CURSOR_HIDE = "\x1b[?25l";
68
+ const ANSI_CURSOR_SHOW = "\x1b[?25h";
69
+ const DIM = (s) => `\x1b[2m${s}\x1b[0m`;
70
+ const BOLD = (s) => `\x1b[1m${s}\x1b[0m`;
71
+
72
+ function cursorUp(n) {
73
+ return n > 0 ? `\x1b[${n}A` : "";
74
+ }
75
+
76
+ /**
77
+ * Render the picker line for a single item.
78
+ * @param {MultiSelectItem} item
79
+ * @param {boolean} checked
80
+ * @param {boolean} active - True if the cursor is on this row.
81
+ * @param {boolean} useColor
82
+ */
83
+ function renderRow(item, checked, active, useColor) {
84
+ const cursor = active ? ">" : " ";
85
+ const box = checked ? "[x]" : "[ ]";
86
+ const hint = item.hint ? ` ${useColor ? DIM(item.hint) : item.hint}` : "";
87
+ const labelPiece = active && useColor ? BOLD(item.label) : item.label;
88
+ return `${cursor} ${box} ${labelPiece}${hint}`;
89
+ }
90
+
91
+ /**
92
+ * Render the full picker frame.
93
+ * @param {MultiSelectItem[]} items
94
+ * @param {Set<number>} checked - Set of indices currently checked.
95
+ * @param {number} activeIdx - Cursor row.
96
+ * @param {boolean} useColor
97
+ */
98
+ function renderFrame(items, checked, activeIdx, useColor) {
99
+ const lines = items.map((item, i) =>
100
+ renderRow(item, checked.has(i), i === activeIdx, useColor),
101
+ );
102
+ return lines.join("\n");
103
+ }
104
+
105
+ /**
106
+ * Drive the TTY picker. Returns the final array of selected keys.
107
+ *
108
+ * @param {string} question
109
+ * @param {MultiSelectItem[]} items
110
+ * @param {NodeJS.ReadableStream} stdin
111
+ * @param {NodeJS.WritableStream} stdout
112
+ * @returns {Promise<string[]>}
113
+ */
114
+ function runTtyPicker(question, items, stdin, stdout) {
115
+ return new Promise((resolve, reject) => {
116
+ const checked = new Set(
117
+ items.map((it, i) => (it.preChecked ? i : -1)).filter((i) => i >= 0),
118
+ );
119
+ let activeIdx = 0;
120
+
121
+ const useColor = !process.env.NO_COLOR;
122
+ const helpLine = useColor
123
+ ? DIM(" ↑/↓ move · space toggle · a toggle all · enter confirm")
124
+ : " ↑/↓ move · space toggle · a toggle all · enter confirm";
125
+
126
+ stdout.write(` ${question}\n`);
127
+ stdout.write(`${helpLine}\n`);
128
+ stdout.write(ANSI_CURSOR_HIDE);
129
+ let firstRender = true;
130
+
131
+ const draw = () => {
132
+ if (!firstRender) {
133
+ // Move back up over the previous frame and clear each line
134
+ // before redrawing. The frame is exactly `items.length` lines.
135
+ stdout.write(cursorUp(items.length));
136
+ for (let i = 0; i < items.length; i++) {
137
+ stdout.write(`${ANSI_CLEAR_LINE}\r`);
138
+ if (i < items.length - 1) stdout.write("\n");
139
+ }
140
+ stdout.write(cursorUp(items.length - 1));
141
+ }
142
+ stdout.write(`${renderFrame(items, checked, activeIdx, useColor)}\n`);
143
+ firstRender = false;
144
+ };
145
+
146
+ draw();
147
+
148
+ emitKeypressEvents(stdin);
149
+ const wasRaw = stdin.isTTY ? stdin.isRaw : false;
150
+ if (stdin.isTTY) stdin.setRawMode(true);
151
+ stdin.resume();
152
+
153
+ const cleanup = () => {
154
+ stdin.removeAllListeners("keypress");
155
+ if (stdin.isTTY) stdin.setRawMode(wasRaw);
156
+ stdin.pause();
157
+ stdout.write(ANSI_CURSOR_SHOW);
158
+ };
159
+
160
+ const onKeypress = (str, key) => {
161
+ if (!key) return;
162
+
163
+ // Ctrl-C / Ctrl-D / Esc → cancel. Resolve with reject so the
164
+ // calling command can exit non-zero rather than silently
165
+ // returning the pre-checked set. Ctrl-D in raw mode does not
166
+ // close the stream automatically, so we have to handle it here
167
+ // or the process hangs.
168
+ if (
169
+ (key.ctrl && (key.name === "c" || key.name === "d")) ||
170
+ key.name === "escape"
171
+ ) {
172
+ cleanup();
173
+ reject(new Error("Multi-select cancelled."));
174
+ return;
175
+ }
176
+
177
+ if (key.name === "up") {
178
+ activeIdx = (activeIdx - 1 + items.length) % items.length;
179
+ draw();
180
+ return;
181
+ }
182
+ if (key.name === "down") {
183
+ activeIdx = (activeIdx + 1) % items.length;
184
+ draw();
185
+ return;
186
+ }
187
+ if (key.name === "space" || str === " ") {
188
+ if (checked.has(activeIdx)) checked.delete(activeIdx);
189
+ else checked.add(activeIdx);
190
+ draw();
191
+ return;
192
+ }
193
+ if (key.name === "a") {
194
+ // Toggle all: if any unchecked, check all; otherwise clear all.
195
+ const allChecked = items.every((_, i) => checked.has(i));
196
+ checked.clear();
197
+ if (!allChecked) {
198
+ for (let i = 0; i < items.length; i++) checked.add(i);
199
+ }
200
+ draw();
201
+ return;
202
+ }
203
+ if (key.name === "return" || key.name === "enter") {
204
+ cleanup();
205
+ const picked = items
206
+ .map((it, i) => (checked.has(i) ? it.key : null))
207
+ .filter((k) => k !== null);
208
+ resolve(picked);
209
+ return;
210
+ }
211
+ };
212
+
213
+ stdin.on("keypress", onKeypress);
214
+ });
215
+ }
216
+
217
+ /**
218
+ * Drive the non-TTY picker. Renders the list once with check markers,
219
+ * then reads a single line from stdin. Empty input → pre-checked
220
+ * defaults; otherwise the input is parsed as a comma-separated list of
221
+ * keys. An unknown key throws.
222
+ *
223
+ * @param {string} question
224
+ * @param {MultiSelectItem[]} items
225
+ * @param {NodeJS.ReadableStream} stdin
226
+ * @param {NodeJS.WritableStream} stdout
227
+ * @returns {Promise<string[]>}
228
+ */
229
+ function runNonTtyPicker(question, items, stdin, stdout) {
230
+ return new Promise((resolve, reject) => {
231
+ stdout.write(` ${question}\n`);
232
+ for (const item of items) {
233
+ const box = item.preChecked ? "[x]" : "[ ]";
234
+ const hint = item.hint ? ` ${item.hint}` : "";
235
+ stdout.write(` ${box} ${item.key.padEnd(12)} ${item.label}${hint}\n`);
236
+ }
237
+ stdout.write(
238
+ " Enter comma-separated keys (or empty to accept the pre-checked defaults): ",
239
+ );
240
+
241
+ let buf = "";
242
+ const onData = (chunk) => {
243
+ buf += chunk.toString("utf-8");
244
+ const newlineIdx = buf.search(/\r?\n/);
245
+ if (newlineIdx === -1) return;
246
+ stdin.removeListener("data", onData);
247
+ stdin.removeListener("end", onEnd);
248
+ stdin.pause();
249
+ finish(buf.slice(0, newlineIdx));
250
+ };
251
+ const onEnd = () => {
252
+ stdin.removeListener("data", onData);
253
+ stdin.removeListener("end", onEnd);
254
+ finish(buf);
255
+ };
256
+
257
+ function finish(line) {
258
+ const trimmed = line.trim();
259
+ if (trimmed === "") {
260
+ resolve(
261
+ items.filter((it) => it.preChecked).map((it) => it.key),
262
+ );
263
+ return;
264
+ }
265
+ const requested = trimmed
266
+ .split(",")
267
+ .map((s) => s.trim())
268
+ .filter(Boolean);
269
+ const validKeys = new Set(items.map((it) => it.key));
270
+ const unknown = requested.filter((r) => !validKeys.has(r));
271
+ if (unknown.length > 0) {
272
+ reject(
273
+ new Error(
274
+ `Unknown key(s): ${unknown.join(", ")}. Valid keys: ${
275
+ [...validKeys].join(", ")
276
+ }.`,
277
+ ),
278
+ );
279
+ return;
280
+ }
281
+ // Dedup while preserving order
282
+ const seen = new Set();
283
+ const result = [];
284
+ for (const key of requested) {
285
+ if (seen.has(key)) continue;
286
+ seen.add(key);
287
+ result.push(key);
288
+ }
289
+ resolve(result);
290
+ }
291
+
292
+ stdin.on("data", onData);
293
+ stdin.on("end", onEnd);
294
+ stdin.resume();
295
+ });
296
+ }
297
+
298
+ /**
299
+ * Multi-select prompt. TTY mode renders an interactive checkbox list;
300
+ * non-TTY mode prints the list and reads a comma-separated line.
301
+ *
302
+ * @param {object} options
303
+ * @param {string} options.question - Prompt question (rendered above the list).
304
+ * @param {MultiSelectItem[]} options.items
305
+ * @param {MultiSelectIo} [io]
306
+ * @returns {Promise<string[]>} - Selected keys, in declaration order.
307
+ */
308
+ export async function promptMultiSelect(options, io = {}) {
309
+ if (!options || typeof options !== "object") {
310
+ throw new TypeError("promptMultiSelect: options is required");
311
+ }
312
+ if (!Array.isArray(options.items) || options.items.length === 0) {
313
+ throw new TypeError("promptMultiSelect: items must be a non-empty array");
314
+ }
315
+ const stdin = io.stdin ?? process.stdin;
316
+ const stdout = io.stdout ?? process.stdout;
317
+ const isTty =
318
+ typeof io.forceTty === "boolean" ? io.forceTty : Boolean(stdout.isTTY);
319
+
320
+ if (isTty) {
321
+ return runTtyPicker(options.question ?? "", options.items, stdin, stdout);
322
+ }
323
+ return runNonTtyPicker(options.question ?? "", options.items, stdin, stdout);
324
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Cohort SessionStart-hook batch remover (#1240). Pairs with the two
3
+ * cohort installer mergers under `mergers/agent-hook-{claude,cursor}-shape.mjs`
4
+ * to satisfy the artifact-registry drift-protection test
5
+ * (`src/test/lib/artifact-registry.test.mjs`), which requires every
6
+ * descriptor to map to a remover.
7
+ *
8
+ * One file rather than per-vendor removers because:
9
+ *
10
+ * 1. The four cohort vendors share a single fingerprint
11
+ * (`AGENT_HOOK_FINGERPRINT`), so all per-vendor removers would
12
+ * delegate to identical logic.
13
+ * 2. The dispatch by `agentHook.shape` is already encoded in
14
+ * `agent-hook-merge.mjs`; reusing it here keeps the two-shape
15
+ * walk in exactly one place.
16
+ * 3. The uninstall command iterates the registry's
17
+ * `kind: "agent-hook"` descriptors and calls this module once per
18
+ * descriptor — the file count would otherwise grow with every
19
+ * cohort vendor added.
20
+ *
21
+ * The artifact-registry CI test maps `removers/agent-hooks.mjs` to the
22
+ * four `agent-hook-<vendorKey>` descriptor ids via `REMOVER_EXPECTED`;
23
+ * any drift in that mapping fails CI before the user can run.
24
+ */
25
+
26
+ import { uninstallAgentHookFor } from "../agent-hook-merge.mjs";
27
+ import { ARTIFACT_REGISTRY } from "../artifact-registry.mjs";
28
+
29
+ /**
30
+ * Remove the cohort hook for a single artifact descriptor. Used by
31
+ * `commands/uninstall.mjs`'s dispatch loop, which routes any
32
+ * `kind: "agent-hook"` descriptor here.
33
+ *
34
+ * @param {object} descriptor - One entry from `ARTIFACT_REGISTRY` with
35
+ * `kind: "agent-hook"`. Must carry `vendorKey`.
36
+ * @param {object} [options]
37
+ * @param {boolean} [options.dryRun=false]
38
+ * @returns {{ path: string; action: "removed" | "would-remove" | "skipped" | "unchanged"; error?: string }}
39
+ */
40
+ export function removeAgentHookArtifact(descriptor, { dryRun = false } = {}) {
41
+ if (!descriptor || descriptor.kind !== "agent-hook") {
42
+ throw new Error(
43
+ `removeAgentHookArtifact: expected kind="agent-hook", got "${descriptor?.kind}".`,
44
+ );
45
+ }
46
+ if (!descriptor.vendorKey) {
47
+ throw new Error(
48
+ `removeAgentHookArtifact: descriptor "${descriptor.id}" missing vendorKey.`,
49
+ );
50
+ }
51
+ return uninstallAgentHookFor(descriptor.vendorKey, { dryRun });
52
+ }
53
+
54
+ /**
55
+ * Remove every cohort hook in one call. Convenience wrapper for
56
+ * `skillrepo uninstall --global` and the standalone session-sync
57
+ * "disable all" workflow. Iterates all `kind: "agent-hook"` descriptors
58
+ * in the artifact registry — i.e. every cohort vendor that has an
59
+ * `agentHook` spec. Idempotent: vendors with no installed hook return
60
+ * `"skipped"` or `"unchanged"`, never failing.
61
+ *
62
+ * @param {object} [options]
63
+ * @param {boolean} [options.dryRun=false]
64
+ * @returns {Array<{ id: string; path: string; action: string; error?: string }>}
65
+ */
66
+ export function removeAllAgentHooks({ dryRun = false } = {}) {
67
+ const results = [];
68
+ for (const descriptor of ARTIFACT_REGISTRY) {
69
+ if (descriptor.kind !== "agent-hook") continue;
70
+ try {
71
+ const r = removeAgentHookArtifact(descriptor, { dryRun });
72
+ results.push({ id: descriptor.id, ...r });
73
+ } catch (err) {
74
+ results.push({
75
+ id: descriptor.id,
76
+ path: descriptor.displayPath,
77
+ action: "failed",
78
+ error: err?.message ?? String(err),
79
+ });
80
+ }
81
+ }
82
+ return results;
83
+ }
package/src/lib/sync.mjs CHANGED
@@ -55,13 +55,14 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
55
55
  import { dirname } from "node:path";
56
56
 
57
57
  import { getLibrary } from "./http.mjs";
58
- import { writeSkillDir, removeSkillDir, cleanupOrphans } from "./file-write.mjs";
59
58
  import {
60
- globalLastSyncPath,
61
- claudeSkillsProject,
62
- claudeSkillsGlobal,
63
- projectSkillsFallback,
64
- } from "./paths.mjs";
59
+ writeSkillDir,
60
+ removeSkillDir,
61
+ cleanupOrphans,
62
+ placementTargetsFor,
63
+ resolvePlacementDir,
64
+ } from "./file-write.mjs";
65
+ import { globalLastSyncPath } from "./paths.mjs";
65
66
  import { diskError, validationError } from "./errors.mjs";
66
67
 
67
68
  /**
@@ -311,21 +312,19 @@ export async function runSync(options) {
311
312
  * per target.
312
313
  */
313
314
  function isAnyTargetPresent(skillName, options) {
314
- if (options.global) {
315
- return existsSync(claudeSkillsGlobal(skillName));
316
- }
317
315
  if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
318
316
  return false;
319
317
  }
320
- if (options.vendors.includes("claudeCode")) {
321
- if (existsSync(claudeSkillsProject(skillName))) return true;
322
- }
323
- if (
324
- options.vendors.includes("cursor") ||
325
- options.vendors.includes("windsurf") ||
326
- options.vendors.includes("vscode")
327
- ) {
328
- if (existsSync(projectSkillsFallback(skillName))) return true;
318
+ let targets;
319
+ try {
320
+ targets = placementTargetsFor({
321
+ vendors: options.vendors,
322
+ global: !!options.global,
323
+ });
324
+ } catch {
325
+ return false;
329
326
  }
330
- return false;
327
+ return targets.some((target) =>
328
+ existsSync(resolvePlacementDir(target, skillName)),
329
+ );
331
330
  }
@@ -152,15 +152,30 @@ describe("runAdd — happy path", () => {
152
152
  assert.ok(dir.startsWith(process.env.HOME));
153
153
  });
154
154
 
155
- it("--ide cursor writes to the project /skills/ fallback", async () => {
155
+ it("--agent cursor writes to .agents/skills/", async () => {
156
156
  server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
157
157
  await runAdd(
158
- ["--key", VALID_KEY, "--url", serverUrl, "--ide", "cursor", "@alice/pdf-helper"],
158
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor", "@alice/pdf-helper"],
159
159
  { stdout },
160
160
  );
161
- const dir = resolvePlacementDir("projectFallback", "pdf-helper");
161
+ const dir = resolvePlacementDir("agentsProject", "pdf-helper");
162
162
  assert.ok(existsSync(dir));
163
163
  });
164
+
165
+ it("rejects --agent none with a validation error (no targets to write)", async () => {
166
+ server.setSkillResponse("alice", "pdf-helper", makeSkill("alice", "pdf-helper"));
167
+ await assert.rejects(
168
+ () =>
169
+ runAdd(
170
+ ["--key", VALID_KEY, "--url", serverUrl, "--agent", "none", "@alice/pdf-helper"],
171
+ { stdout },
172
+ ),
173
+ (err) =>
174
+ err instanceof CliError &&
175
+ err.exitCode === EXIT_VALIDATION &&
176
+ /--agent none has no effect on `skillrepo add`/.test(err.message),
177
+ );
178
+ });
164
179
  });
165
180
 
166
181
  describe("runAdd — error paths", () => {
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Unit tests for init.mjs's picker-row helpers (#1252 QA round —
3
+ * coverage gap: "(already configured — re-checking will refresh)"
4
+ * annotation).
5
+ *
6
+ * Why a separate file: init.test.mjs is the integration test for
7
+ * `runInit` — it spins up a mock server and the full credential +
8
+ * detection + picker + sync pipeline. The annotation lives in two
9
+ * pure helpers (`formatHint`, `directoryHasContent`) that don't need
10
+ * any of that — direct unit tests are cheaper and lock the exact
11
+ * string and state semantics the picker renders.
12
+ *
13
+ * Coverage goals:
14
+ * 1. The annotation string is the literal phrase the picker shows
15
+ * to the user. A future refactor that paraphrases it (e.g.
16
+ * "(re-running will refresh)") must fail this test.
17
+ * 2. The "(not detected — leave checked if you use one)" fallback
18
+ * is the wording change the v3.1.x copy review settled on,
19
+ * replacing the older "(no signal — opt in if you use one)".
20
+ * Pinning the current wording keeps that decision visible.
21
+ * 3. directoryHasContent treats missing, empty, and populated
22
+ * directories with the three states the picker layer assumes.
23
+ */
24
+
25
+ import { describe, it, beforeEach, afterEach } from "node:test";
26
+ import assert from "node:assert/strict";
27
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
28
+ import { join } from "node:path";
29
+ import { tmpdir } from "node:os";
30
+
31
+ import { directoryHasContent, formatHint } from "../../commands/init.mjs";
32
+
33
+ let sandbox;
34
+
35
+ function setupSandbox() {
36
+ sandbox = mkdtempSync(join(tmpdir(), "cli-init-picker-"));
37
+ }
38
+
39
+ function teardownSandbox() {
40
+ if (sandbox) rmSync(sandbox, { recursive: true, force: true });
41
+ sandbox = undefined;
42
+ }
43
+
44
+ // ── formatHint ──────────────────────────────────────────────────────────
45
+
46
+ describe("formatHint — already-configured annotation", () => {
47
+ it("includes the annotation when alreadyConfigured is true and a detection reason fired", () => {
48
+ const hint = formatHint({
49
+ detectionReason: "CLAUDECODE=1",
50
+ pathLabel: ".claude/skills/",
51
+ alreadyConfigured: true,
52
+ });
53
+ // The annotation is the load-bearing string here — the QA round
54
+ // flagged that init.mjs:846 had this phrase and zero coverage.
55
+ // Match the literal so a copy edit forces an explicit test
56
+ // update.
57
+ assert.match(
58
+ hint,
59
+ /\(already configured — re-checking will refresh\)/,
60
+ );
61
+ // The detection reason still renders alongside the annotation.
62
+ assert.match(hint, /\(detected: CLAUDECODE=1\)/);
63
+ // Path label leads the hint (rendering invariant the picker UI
64
+ // depends on for column alignment).
65
+ assert.ok(hint.startsWith(".claude/skills/"), `hint should start with path label, got "${hint}"`);
66
+ });
67
+
68
+ it("does NOT include the annotation when alreadyConfigured is false but a detection reason fired", () => {
69
+ const hint = formatHint({
70
+ detectionReason: ".cursor/",
71
+ pathLabel: ".agents/skills/",
72
+ alreadyConfigured: false,
73
+ });
74
+ assert.match(hint, /\(detected: \.cursor\/\)/);
75
+ // Negative assertion: the annotation must not appear.
76
+ assert.doesNotMatch(hint, /already configured/);
77
+ });
78
+
79
+ it("returns the no-signal fallback when alreadyConfigured is false and no detection reason fired", () => {
80
+ const hint = formatHint({
81
+ detectionReason: null,
82
+ pathLabel: ".claude/skills/",
83
+ alreadyConfigured: false,
84
+ });
85
+ // The fallback was reworded in the v3.1.x copy review from
86
+ // "(no signal — opt in if you use one)" to the current text.
87
+ // Match the live wording so a future revert is caught here.
88
+ assert.match(
89
+ hint,
90
+ /\(not detected — leave checked if you use one\)/,
91
+ );
92
+ assert.doesNotMatch(hint, /already configured/);
93
+ });
94
+
95
+ it("returns the no-signal fallback PLUS the annotation when alreadyConfigured is true and no detection fired", () => {
96
+ // This is the genuine "user already has skills written here from
97
+ // a prior run, but no live detection signal" case. The picker
98
+ // should still annotate the row so the user understands a
99
+ // re-check refreshes the existing target.
100
+ const hint = formatHint({
101
+ detectionReason: null,
102
+ pathLabel: ".agents/skills/",
103
+ alreadyConfigured: true,
104
+ });
105
+ assert.match(hint, /\(not detected — leave checked if you use one\)/);
106
+ assert.match(hint, /\(already configured — re-checking will refresh\)/);
107
+ });
108
+ });
109
+
110
+ // ── directoryHasContent ─────────────────────────────────────────────────
111
+
112
+ describe("directoryHasContent", () => {
113
+ beforeEach(setupSandbox);
114
+ afterEach(teardownSandbox);
115
+
116
+ it("returns false for a path that does not exist", () => {
117
+ // Use join() so the absent path is platform-correct (Windows
118
+ // forward-slashes are tolerated by node:fs but not idiomatic).
119
+ const missing = join(sandbox, "does-not-exist", "ghost");
120
+ assert.equal(directoryHasContent(missing), false);
121
+ });
122
+
123
+ it("returns false for an existing but empty directory", () => {
124
+ const emptyDir = join(sandbox, "empty");
125
+ mkdirSync(emptyDir, { recursive: true });
126
+ assert.equal(directoryHasContent(emptyDir), false);
127
+ });
128
+
129
+ it("returns true for a directory containing a file", () => {
130
+ const dir = join(sandbox, "has-file");
131
+ mkdirSync(dir, { recursive: true });
132
+ writeFileSync(join(dir, "marker.txt"), "x");
133
+ assert.equal(directoryHasContent(dir), true);
134
+ });
135
+
136
+ it("returns true for a directory containing a subdirectory", () => {
137
+ // A previous init writes <root>/<skill-name>/SKILL.md, so the
138
+ // typical "already configured" signal is a child directory, not
139
+ // a child file at the root.
140
+ const dir = join(sandbox, "has-subdir");
141
+ mkdirSync(join(dir, "pdf-helper"), { recursive: true });
142
+ assert.equal(directoryHasContent(dir), true);
143
+ });
144
+ });