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.
- package/README.md +137 -27
- package/bin/skillrepo.mjs +5 -5
- package/package.json +1 -1
- package/src/commands/add.mjs +21 -6
- package/src/commands/get.mjs +20 -4
- package/src/commands/init-cohort-hooks.mjs +127 -0
- package/src/commands/init-session-sync.mjs +1 -1
- package/src/commands/init.mjs +480 -117
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +13 -2
- package/src/commands/update.mjs +112 -19
- package/src/lib/agent-hook-merge.mjs +203 -0
- package/src/lib/agent-registry.mjs +399 -0
- package/src/lib/artifact-registry.mjs +111 -2
- package/src/lib/cli-config.mjs +146 -44
- package/src/lib/detect-agents.mjs +112 -0
- package/src/lib/file-write.mjs +162 -77
- package/src/lib/fs-utils.mjs +16 -1
- package/src/lib/mcp-merge.mjs +17 -36
- package/src/lib/mergers/agent-hook-claude-shape.mjs +519 -0
- package/src/lib/mergers/agent-hook-cursor-shape.mjs +318 -0
- package/src/lib/mergers/gitignore.mjs +55 -28
- package/src/lib/paths.mjs +27 -25
- package/src/lib/prompt-multiselect.mjs +324 -0
- package/src/lib/removers/agent-hooks.mjs +83 -0
- package/src/lib/sync.mjs +18 -19
- package/src/test/commands/add.test.mjs +18 -3
- package/src/test/commands/init-picker.test.mjs +144 -0
- package/src/test/commands/init.test.mjs +508 -41
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +148 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-cohort-hooks.test.mjs +393 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/agent-hooks.integration.test.mjs +340 -0
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-hook-merge.test.mjs +172 -0
- package/src/test/lib/agent-registry.test.mjs +215 -0
- package/src/test/lib/artifact-registry.test.mjs +39 -0
- package/src/test/lib/cli-config.test.mjs +222 -38
- package/src/test/lib/detect-agents.test.mjs +336 -0
- package/src/test/lib/file-write-placement.test.mjs +264 -0
- package/src/test/lib/file-write.test.mjs +231 -30
- package/src/test/lib/mcp-merge.test.mjs +23 -15
- package/src/test/lib/paths.test.mjs +53 -17
- package/src/test/lib/prompt-multiselect.test.mjs +448 -0
- package/src/test/lib/sync.test.mjs +157 -0
- package/src/test/mergers/agent-hook-claude-shape.test.mjs +518 -0
- package/src/test/mergers/agent-hook-cursor-shape.test.mjs +306 -0
- package/src/test/removers/agent-hooks.test.mjs +206 -0
- package/src/lib/detect-ides.mjs +0 -44
- 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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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("--
|
|
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, "--
|
|
158
|
+
["--key", VALID_KEY, "--url", serverUrl, "--agent", "cursor", "@alice/pdf-helper"],
|
|
159
159
|
{ stdout },
|
|
160
160
|
);
|
|
161
|
-
const dir = resolvePlacementDir("
|
|
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
|
+
});
|