skillrepo 3.2.0 → 4.0.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 +90 -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-session-sync.mjs +1 -1
- package/src/commands/init.mjs +435 -111
- package/src/commands/list.mjs +1 -1
- package/src/commands/remove.mjs +10 -2
- package/src/commands/uninstall.mjs +1 -1
- package/src/commands/update.mjs +15 -3
- package/src/lib/agent-registry.mjs +215 -0
- 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/mcp-merge.mjs +17 -36
- 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/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 +228 -42
- package/src/test/commands/remove.test.mjs +4 -1
- package/src/test/commands/update.test.mjs +13 -3
- package/src/test/e2e/cli-agent-permutations.test.mjs +631 -0
- package/src/test/e2e/cli-commands.test.mjs +39 -13
- package/src/test/integration/file-write.integration.test.mjs +31 -10
- package/src/test/lib/agent-registry.test.mjs +215 -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/lib/detect-ides.mjs +0 -44
- package/src/test/detect-ides.test.mjs +0 -65
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unit tests for the
|
|
2
|
+
* Unit tests for the skill placement path resolvers in paths.mjs.
|
|
3
3
|
*
|
|
4
4
|
* The pre-existing exports (claudeMcpJson, cursorMcpJson, etc.) are
|
|
5
5
|
* exercised indirectly by the existing mergers tests; this file
|
|
6
|
-
* focuses on the
|
|
6
|
+
* focuses on the skill placement + gitignore exports.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
@@ -15,10 +15,14 @@ import { tmpdir, homedir } from "node:os";
|
|
|
15
15
|
import {
|
|
16
16
|
claudeSkillsProject,
|
|
17
17
|
claudeSkillsGlobal,
|
|
18
|
-
projectSkillsFallback,
|
|
19
|
-
projectSkillsFallbackRoot,
|
|
20
18
|
claudeSkillsProjectRoot,
|
|
21
19
|
claudeSkillsGlobalRoot,
|
|
20
|
+
agentsSkillsProject,
|
|
21
|
+
agentsSkillsProjectRoot,
|
|
22
|
+
agentsSkillsGlobal,
|
|
23
|
+
agentsSkillsGlobalRoot,
|
|
24
|
+
windsurfSkillsGlobal,
|
|
25
|
+
windsurfSkillsGlobalRoot,
|
|
22
26
|
gitignorePath,
|
|
23
27
|
} from "../../lib/paths.mjs";
|
|
24
28
|
import {
|
|
@@ -59,28 +63,60 @@ describe("paths.mjs — skill placement targets", () => {
|
|
|
59
63
|
|
|
60
64
|
it("claudeSkillsGlobal is under HOME/.claude/skills/<name>", () => {
|
|
61
65
|
const dir = claudeSkillsGlobal("pdf-helper");
|
|
62
|
-
// homedir() reads HOME on POSIX so respects our sandbox
|
|
63
66
|
assert.equal(dir, join(homedir(), ".claude", "skills", "pdf-helper"));
|
|
64
67
|
});
|
|
65
68
|
|
|
66
|
-
it("projectSkillsFallback is under cwd/skills/<name>", () => {
|
|
67
|
-
const dir = projectSkillsFallback("pdf-helper");
|
|
68
|
-
assert.equal(dir, join(process.cwd(), "skills", "pdf-helper"));
|
|
69
|
-
});
|
|
70
|
-
|
|
71
69
|
it("claudeSkillsProjectRoot is the parent of project-local skills", () => {
|
|
72
|
-
|
|
73
|
-
|
|
70
|
+
assert.equal(
|
|
71
|
+
claudeSkillsProjectRoot(),
|
|
72
|
+
join(process.cwd(), ".claude", "skills"),
|
|
73
|
+
);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
it("claudeSkillsGlobalRoot is the parent of personal skills", () => {
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
assert.equal(claudeSkillsGlobalRoot(), join(homedir(), ".claude", "skills"));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("agentsSkillsProject is under cwd/.agents/skills/<name>", () => {
|
|
81
|
+
assert.equal(
|
|
82
|
+
agentsSkillsProject("pdf-helper"),
|
|
83
|
+
join(process.cwd(), ".agents", "skills", "pdf-helper"),
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("agentsSkillsProjectRoot is the parent of cohort skills", () => {
|
|
88
|
+
assert.equal(
|
|
89
|
+
agentsSkillsProjectRoot(),
|
|
90
|
+
join(process.cwd(), ".agents", "skills"),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("agentsSkillsGlobal is under HOME/.agents/skills/<name>", () => {
|
|
95
|
+
assert.equal(
|
|
96
|
+
agentsSkillsGlobal("pdf-helper"),
|
|
97
|
+
join(homedir(), ".agents", "skills", "pdf-helper"),
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("agentsSkillsGlobalRoot is the parent of personal cohort skills", () => {
|
|
102
|
+
assert.equal(
|
|
103
|
+
agentsSkillsGlobalRoot(),
|
|
104
|
+
join(homedir(), ".agents", "skills"),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("windsurfSkillsGlobal is under HOME/.codeium/windsurf/skills/<name>", () => {
|
|
109
|
+
assert.equal(
|
|
110
|
+
windsurfSkillsGlobal("pdf-helper"),
|
|
111
|
+
join(homedir(), ".codeium", "windsurf", "skills", "pdf-helper"),
|
|
112
|
+
);
|
|
79
113
|
});
|
|
80
114
|
|
|
81
|
-
it("
|
|
82
|
-
|
|
83
|
-
|
|
115
|
+
it("windsurfSkillsGlobalRoot is the parent of Windsurf personal skills", () => {
|
|
116
|
+
assert.equal(
|
|
117
|
+
windsurfSkillsGlobalRoot(),
|
|
118
|
+
join(homedir(), ".codeium", "windsurf", "skills"),
|
|
119
|
+
);
|
|
84
120
|
});
|
|
85
121
|
|
|
86
122
|
it("gitignorePath is at cwd/.gitignore", () => {
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for src/lib/prompt-multiselect.mjs (#1236, Phase 3 of #876).
|
|
3
|
+
*
|
|
4
|
+
* Coverage scope:
|
|
5
|
+
*
|
|
6
|
+
* - Non-TTY mode (the comma-separated parser): empty input, single
|
|
7
|
+
* key, multi key, unknown-key rejection, dedup, override semantics.
|
|
8
|
+
*
|
|
9
|
+
* - TTY mode is exercised lightly. Full keypress simulation requires
|
|
10
|
+
* emitting parsed key events into a fake stdin, which is doable
|
|
11
|
+
* via the same `Readable` shape Node's keypress parser accepts.
|
|
12
|
+
* We exercise the renderer indirectly through one round-trip
|
|
13
|
+
* (toggle → enter) to catch wiring regressions, but most TTY
|
|
14
|
+
* behavior is covered by the integration test in init.test.mjs
|
|
15
|
+
* once the picker is wired in.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, it } from "node:test";
|
|
19
|
+
import assert from "node:assert/strict";
|
|
20
|
+
import { Readable, Writable } from "node:stream";
|
|
21
|
+
|
|
22
|
+
import { promptMultiSelect } from "../../lib/prompt-multiselect.mjs";
|
|
23
|
+
|
|
24
|
+
function makeFakeStdin(input) {
|
|
25
|
+
// A minimal Readable that emits the input as a single chunk and
|
|
26
|
+
// ends. Node's readline.emitKeypressEvents will not be used in
|
|
27
|
+
// non-TTY mode — runNonTtyPicker reads raw chunks.
|
|
28
|
+
const stream = Readable.from([input], { objectMode: false });
|
|
29
|
+
stream.isTTY = false;
|
|
30
|
+
return stream;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function makeFakeStdout() {
|
|
34
|
+
const chunks = [];
|
|
35
|
+
const stream = new Writable({
|
|
36
|
+
write(chunk, _enc, cb) {
|
|
37
|
+
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
|
|
38
|
+
cb();
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
stream.isTTY = false;
|
|
42
|
+
stream.text = () => chunks.join("");
|
|
43
|
+
return stream;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const ITEMS = [
|
|
47
|
+
{ key: "claude", label: "Claude Code", hint: ".claude/skills/", preChecked: true },
|
|
48
|
+
{ key: "agents", label: "Other agents", hint: ".agents/skills/", preChecked: true },
|
|
49
|
+
{ key: "none", label: "None — I'll configure manually", preChecked: false },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
// ── Non-TTY: empty input ────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
describe("promptMultiSelect — non-TTY: empty input", () => {
|
|
55
|
+
it("returns the pre-checked items when stdin is empty (just newline)", async () => {
|
|
56
|
+
const stdin = makeFakeStdin("\n");
|
|
57
|
+
const stdout = makeFakeStdout();
|
|
58
|
+
const result = await promptMultiSelect(
|
|
59
|
+
{ question: "Pick targets:", items: ITEMS },
|
|
60
|
+
{ stdin, stdout, forceTty: false },
|
|
61
|
+
);
|
|
62
|
+
assert.deepEqual(result, ["claude", "agents"]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns the pre-checked items when stdin is just whitespace", async () => {
|
|
66
|
+
const stdin = makeFakeStdin(" \n");
|
|
67
|
+
const stdout = makeFakeStdout();
|
|
68
|
+
const result = await promptMultiSelect(
|
|
69
|
+
{ question: "Pick targets:", items: ITEMS },
|
|
70
|
+
{ stdin, stdout, forceTty: false },
|
|
71
|
+
);
|
|
72
|
+
assert.deepEqual(result, ["claude", "agents"]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns empty array when no items are pre-checked and input is empty", async () => {
|
|
76
|
+
const stdin = makeFakeStdin("\n");
|
|
77
|
+
const stdout = makeFakeStdout();
|
|
78
|
+
const items = ITEMS.map((it) => ({ ...it, preChecked: false }));
|
|
79
|
+
const result = await promptMultiSelect(
|
|
80
|
+
{ question: "?", items },
|
|
81
|
+
{ stdin, stdout, forceTty: false },
|
|
82
|
+
);
|
|
83
|
+
assert.deepEqual(result, []);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ── Non-TTY: explicit input ────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
describe("promptMultiSelect — non-TTY: explicit input", () => {
|
|
90
|
+
it("returns just the requested key for a single-key input", async () => {
|
|
91
|
+
const stdin = makeFakeStdin("claude\n");
|
|
92
|
+
const stdout = makeFakeStdout();
|
|
93
|
+
const result = await promptMultiSelect(
|
|
94
|
+
{ question: "?", items: ITEMS },
|
|
95
|
+
{ stdin, stdout, forceTty: false },
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(result, ["claude"]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("parses comma-separated input", async () => {
|
|
101
|
+
const stdin = makeFakeStdin("claude,agents\n");
|
|
102
|
+
const stdout = makeFakeStdout();
|
|
103
|
+
const result = await promptMultiSelect(
|
|
104
|
+
{ question: "?", items: ITEMS },
|
|
105
|
+
{ stdin, stdout, forceTty: false },
|
|
106
|
+
);
|
|
107
|
+
assert.deepEqual(result, ["claude", "agents"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("trims whitespace around comma-separated tokens", async () => {
|
|
111
|
+
const stdin = makeFakeStdin(" claude , agents \n");
|
|
112
|
+
const stdout = makeFakeStdout();
|
|
113
|
+
const result = await promptMultiSelect(
|
|
114
|
+
{ question: "?", items: ITEMS },
|
|
115
|
+
{ stdin, stdout, forceTty: false },
|
|
116
|
+
);
|
|
117
|
+
assert.deepEqual(result, ["claude", "agents"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("explicit input overrides pre-checked defaults", async () => {
|
|
121
|
+
// Both `claude` and `agents` are pre-checked, but the user types
|
|
122
|
+
// only `none` — the result must be `["none"]`, not the union.
|
|
123
|
+
const stdin = makeFakeStdin("none\n");
|
|
124
|
+
const stdout = makeFakeStdout();
|
|
125
|
+
const result = await promptMultiSelect(
|
|
126
|
+
{ question: "?", items: ITEMS },
|
|
127
|
+
{ stdin, stdout, forceTty: false },
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(result, ["none"]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("dedupes repeated keys in input", async () => {
|
|
133
|
+
const stdin = makeFakeStdin("claude,claude,agents\n");
|
|
134
|
+
const stdout = makeFakeStdout();
|
|
135
|
+
const result = await promptMultiSelect(
|
|
136
|
+
{ question: "?", items: ITEMS },
|
|
137
|
+
{ stdin, stdout, forceTty: false },
|
|
138
|
+
);
|
|
139
|
+
assert.deepEqual(result, ["claude", "agents"]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("rejects unknown keys with a clear error listing valid keys", async () => {
|
|
143
|
+
const stdin = makeFakeStdin("foo\n");
|
|
144
|
+
const stdout = makeFakeStdout();
|
|
145
|
+
await assert.rejects(
|
|
146
|
+
() =>
|
|
147
|
+
promptMultiSelect(
|
|
148
|
+
{ question: "?", items: ITEMS },
|
|
149
|
+
{ stdin, stdout, forceTty: false },
|
|
150
|
+
),
|
|
151
|
+
(err) =>
|
|
152
|
+
err instanceof Error &&
|
|
153
|
+
/Unknown key/.test(err.message) &&
|
|
154
|
+
/claude/.test(err.message) &&
|
|
155
|
+
/agents/.test(err.message) &&
|
|
156
|
+
/none/.test(err.message),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects when input contains a single unknown alongside valid keys", async () => {
|
|
161
|
+
const stdin = makeFakeStdin("claude,foo\n");
|
|
162
|
+
const stdout = makeFakeStdout();
|
|
163
|
+
await assert.rejects(
|
|
164
|
+
() =>
|
|
165
|
+
promptMultiSelect(
|
|
166
|
+
{ question: "?", items: ITEMS },
|
|
167
|
+
{ stdin, stdout, forceTty: false },
|
|
168
|
+
),
|
|
169
|
+
(err) => err instanceof Error && /foo/.test(err.message),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("handles \\r\\n line endings (Windows-style)", async () => {
|
|
174
|
+
// Cross-platform check: Windows piped input often arrives with
|
|
175
|
+
// CRLF. The parser must tolerate both \n and \r\n.
|
|
176
|
+
const stdin = makeFakeStdin("claude\r\n");
|
|
177
|
+
const stdout = makeFakeStdout();
|
|
178
|
+
const result = await promptMultiSelect(
|
|
179
|
+
{ question: "?", items: ITEMS },
|
|
180
|
+
{ stdin, stdout, forceTty: false },
|
|
181
|
+
);
|
|
182
|
+
assert.deepEqual(result, ["claude"]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// ── Non-TTY: rendered output ────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe("promptMultiSelect — non-TTY: rendering", () => {
|
|
189
|
+
it("prints the question and every item", async () => {
|
|
190
|
+
const stdin = makeFakeStdin("\n");
|
|
191
|
+
const stdout = makeFakeStdout();
|
|
192
|
+
await promptMultiSelect(
|
|
193
|
+
{ question: "Pick targets:", items: ITEMS },
|
|
194
|
+
{ stdin, stdout, forceTty: false },
|
|
195
|
+
);
|
|
196
|
+
const text = stdout.text();
|
|
197
|
+
assert.match(text, /Pick targets:/);
|
|
198
|
+
assert.match(text, /claude/);
|
|
199
|
+
assert.match(text, /Claude Code/);
|
|
200
|
+
assert.match(text, /agents/);
|
|
201
|
+
assert.match(text, /Other agents/);
|
|
202
|
+
assert.match(text, /none/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("includes hints in the rendered output", async () => {
|
|
206
|
+
const stdin = makeFakeStdin("\n");
|
|
207
|
+
const stdout = makeFakeStdout();
|
|
208
|
+
await promptMultiSelect(
|
|
209
|
+
{ question: "?", items: ITEMS },
|
|
210
|
+
{ stdin, stdout, forceTty: false },
|
|
211
|
+
);
|
|
212
|
+
const text = stdout.text();
|
|
213
|
+
assert.match(text, /\.claude\/skills\//);
|
|
214
|
+
assert.match(text, /\.agents\/skills\//);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("renders pre-checked items with [x] and unchecked with [ ]", async () => {
|
|
218
|
+
const stdin = makeFakeStdin("\n");
|
|
219
|
+
const stdout = makeFakeStdout();
|
|
220
|
+
await promptMultiSelect(
|
|
221
|
+
{ question: "?", items: ITEMS },
|
|
222
|
+
{ stdin, stdout, forceTty: false },
|
|
223
|
+
);
|
|
224
|
+
const text = stdout.text();
|
|
225
|
+
// Two pre-checked items: [x] should appear at least twice.
|
|
226
|
+
const checkedMatches = text.match(/\[x\]/g) ?? [];
|
|
227
|
+
assert.ok(checkedMatches.length >= 2, "two items should render as [x]");
|
|
228
|
+
// One unchecked: [ ] should appear at least once.
|
|
229
|
+
assert.match(text, /\[ \]/);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
// ── TTY mode: keypress simulation ───────────────────────────────────
|
|
234
|
+
//
|
|
235
|
+
// We construct a fake stdin that satisfies the picker's expectations
|
|
236
|
+
// (isTTY=true, setRawMode, pause/resume, on/removeAllListeners) and
|
|
237
|
+
// directly emit "keypress" events. This bypasses readline's keypress
|
|
238
|
+
// parser — which is fine, because we're testing the picker's reaction
|
|
239
|
+
// to parsed events, not the parser itself.
|
|
240
|
+
|
|
241
|
+
import { EventEmitter } from "node:events";
|
|
242
|
+
|
|
243
|
+
function makeTtyStdin() {
|
|
244
|
+
const stdin = new EventEmitter();
|
|
245
|
+
stdin.isTTY = true;
|
|
246
|
+
stdin.isRaw = false;
|
|
247
|
+
stdin.setRawMode = function (raw) {
|
|
248
|
+
this.isRaw = raw;
|
|
249
|
+
return this;
|
|
250
|
+
};
|
|
251
|
+
stdin.pause = function () {};
|
|
252
|
+
stdin.resume = function () {};
|
|
253
|
+
// Drive a sequence of keypress events on the next tick so the picker
|
|
254
|
+
// has time to register its listener via `stdin.on("keypress", ...)`.
|
|
255
|
+
stdin.send = (events) => {
|
|
256
|
+
queueMicrotask(() => {
|
|
257
|
+
for (const [str, key] of events) {
|
|
258
|
+
stdin.emit("keypress", str, key);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
};
|
|
262
|
+
return stdin;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function makeTtyStdout() {
|
|
266
|
+
const chunks = [];
|
|
267
|
+
const stream = new Writable({
|
|
268
|
+
write(chunk, _enc, cb) {
|
|
269
|
+
chunks.push(typeof chunk === "string" ? chunk : chunk.toString("utf-8"));
|
|
270
|
+
cb();
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
stream.isTTY = true;
|
|
274
|
+
stream.text = () => chunks.join("");
|
|
275
|
+
return stream;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
describe("promptMultiSelect — TTY: keypress handling", () => {
|
|
279
|
+
it("returns pre-checked items when user presses enter immediately", async () => {
|
|
280
|
+
const stdin = makeTtyStdin();
|
|
281
|
+
const stdout = makeTtyStdout();
|
|
282
|
+
stdin.send([[null, { name: "return" }]]);
|
|
283
|
+
const result = await promptMultiSelect(
|
|
284
|
+
{ question: "?", items: ITEMS },
|
|
285
|
+
{ stdin, stdout, forceTty: true },
|
|
286
|
+
);
|
|
287
|
+
assert.deepEqual(result, ["claude", "agents"]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("space toggles the active row off; enter confirms", async () => {
|
|
291
|
+
const stdin = makeTtyStdin();
|
|
292
|
+
const stdout = makeTtyStdout();
|
|
293
|
+
// Cursor starts on index 0 (claude). Space → uncheck claude. Enter.
|
|
294
|
+
stdin.send([
|
|
295
|
+
[" ", { name: "space" }],
|
|
296
|
+
[null, { name: "return" }],
|
|
297
|
+
]);
|
|
298
|
+
const result = await promptMultiSelect(
|
|
299
|
+
{ question: "?", items: ITEMS },
|
|
300
|
+
{ stdin, stdout, forceTty: true },
|
|
301
|
+
);
|
|
302
|
+
assert.deepEqual(result, ["agents"]);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("down arrow then space toggles the cohort row off", async () => {
|
|
306
|
+
const stdin = makeTtyStdin();
|
|
307
|
+
const stdout = makeTtyStdout();
|
|
308
|
+
// Down → cursor on agents. Space → uncheck. Enter.
|
|
309
|
+
stdin.send([
|
|
310
|
+
[null, { name: "down" }],
|
|
311
|
+
[" ", { name: "space" }],
|
|
312
|
+
[null, { name: "return" }],
|
|
313
|
+
]);
|
|
314
|
+
const result = await promptMultiSelect(
|
|
315
|
+
{ question: "?", items: ITEMS },
|
|
316
|
+
{ stdin, stdout, forceTty: true },
|
|
317
|
+
);
|
|
318
|
+
assert.deepEqual(result, ["claude"]);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("up arrow wraps from index 0 to the last row", async () => {
|
|
322
|
+
const stdin = makeTtyStdin();
|
|
323
|
+
const stdout = makeTtyStdout();
|
|
324
|
+
// Up from index 0 → wraps to index 2 (none). Space → check none. Enter.
|
|
325
|
+
stdin.send([
|
|
326
|
+
[null, { name: "up" }],
|
|
327
|
+
[" ", { name: "space" }],
|
|
328
|
+
[null, { name: "return" }],
|
|
329
|
+
]);
|
|
330
|
+
const result = await promptMultiSelect(
|
|
331
|
+
{ question: "?", items: ITEMS },
|
|
332
|
+
{ stdin, stdout, forceTty: true },
|
|
333
|
+
);
|
|
334
|
+
// claude + agents pre-checked + none toggled on
|
|
335
|
+
assert.deepEqual(result, ["claude", "agents", "none"]);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("'a' toggles all items on when at least one is unchecked", async () => {
|
|
339
|
+
const stdin = makeTtyStdin();
|
|
340
|
+
const stdout = makeTtyStdout();
|
|
341
|
+
// ITEMS has none pre-checked false. 'a' → check all. Enter.
|
|
342
|
+
stdin.send([
|
|
343
|
+
["a", { name: "a" }],
|
|
344
|
+
[null, { name: "return" }],
|
|
345
|
+
]);
|
|
346
|
+
const result = await promptMultiSelect(
|
|
347
|
+
{ question: "?", items: ITEMS },
|
|
348
|
+
{ stdin, stdout, forceTty: true },
|
|
349
|
+
);
|
|
350
|
+
assert.deepEqual(result, ["claude", "agents", "none"]);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("'a' clears all when every item is already checked", async () => {
|
|
354
|
+
const stdin = makeTtyStdin();
|
|
355
|
+
const stdout = makeTtyStdout();
|
|
356
|
+
const allPreChecked = ITEMS.map((it) => ({ ...it, preChecked: true }));
|
|
357
|
+
stdin.send([
|
|
358
|
+
["a", { name: "a" }],
|
|
359
|
+
[null, { name: "return" }],
|
|
360
|
+
]);
|
|
361
|
+
const result = await promptMultiSelect(
|
|
362
|
+
{ question: "?", items: allPreChecked },
|
|
363
|
+
{ stdin, stdout, forceTty: true },
|
|
364
|
+
);
|
|
365
|
+
assert.deepEqual(result, []);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("Ctrl+C cancels with a rejection", async () => {
|
|
369
|
+
const stdin = makeTtyStdin();
|
|
370
|
+
const stdout = makeTtyStdout();
|
|
371
|
+
stdin.send([[null, { name: "c", ctrl: true }]]);
|
|
372
|
+
await assert.rejects(
|
|
373
|
+
() =>
|
|
374
|
+
promptMultiSelect(
|
|
375
|
+
{ question: "?", items: ITEMS },
|
|
376
|
+
{ stdin, stdout, forceTty: true },
|
|
377
|
+
),
|
|
378
|
+
/cancelled/i,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("Ctrl+D cancels with a rejection (does not hang the process)", async () => {
|
|
383
|
+
// Round-2 review fix: Ctrl+D in raw mode does not close the stream
|
|
384
|
+
// automatically; without explicit handling the process hangs.
|
|
385
|
+
const stdin = makeTtyStdin();
|
|
386
|
+
const stdout = makeTtyStdout();
|
|
387
|
+
stdin.send([[null, { name: "d", ctrl: true }]]);
|
|
388
|
+
await assert.rejects(
|
|
389
|
+
() =>
|
|
390
|
+
promptMultiSelect(
|
|
391
|
+
{ question: "?", items: ITEMS },
|
|
392
|
+
{ stdin, stdout, forceTty: true },
|
|
393
|
+
),
|
|
394
|
+
/cancelled/i,
|
|
395
|
+
);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("Esc cancels with a rejection", async () => {
|
|
399
|
+
const stdin = makeTtyStdin();
|
|
400
|
+
const stdout = makeTtyStdout();
|
|
401
|
+
stdin.send([[null, { name: "escape" }]]);
|
|
402
|
+
await assert.rejects(
|
|
403
|
+
() =>
|
|
404
|
+
promptMultiSelect(
|
|
405
|
+
{ question: "?", items: ITEMS },
|
|
406
|
+
{ stdin, stdout, forceTty: true },
|
|
407
|
+
),
|
|
408
|
+
/cancelled/i,
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("restores raw mode to its prior value after enter", async () => {
|
|
413
|
+
const stdin = makeTtyStdin();
|
|
414
|
+
const stdout = makeTtyStdout();
|
|
415
|
+
stdin.isRaw = false;
|
|
416
|
+
stdin.send([[null, { name: "return" }]]);
|
|
417
|
+
await promptMultiSelect(
|
|
418
|
+
{ question: "?", items: ITEMS },
|
|
419
|
+
{ stdin, stdout, forceTty: true },
|
|
420
|
+
);
|
|
421
|
+
assert.equal(stdin.isRaw, false, "raw mode must be restored");
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
// ── Argument validation ─────────────────────────────────────────────
|
|
426
|
+
|
|
427
|
+
describe("promptMultiSelect — argument validation", () => {
|
|
428
|
+
it("throws TypeError when items is missing", async () => {
|
|
429
|
+
await assert.rejects(
|
|
430
|
+
() => promptMultiSelect({ question: "?" }, {}),
|
|
431
|
+
TypeError,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("throws TypeError when items is empty", async () => {
|
|
436
|
+
await assert.rejects(
|
|
437
|
+
() => promptMultiSelect({ question: "?", items: [] }, {}),
|
|
438
|
+
TypeError,
|
|
439
|
+
);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
it("throws TypeError when options is missing", async () => {
|
|
443
|
+
await assert.rejects(
|
|
444
|
+
() => promptMultiSelect(undefined, {}),
|
|
445
|
+
TypeError,
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
});
|