@b0tts/template-dev-installer 1.2.0 → 1.3.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/bin/cli.mjs CHANGED
@@ -64,7 +64,7 @@ function warnIfExists(destPath) {
64
64
  }
65
65
  }
66
66
 
67
- function copyItem(item) {
67
+ function copyItem(item, skillsFilter) {
68
68
  const src = resolve(FILES, item.src);
69
69
  const dest = resolve(CWD, item.dest);
70
70
  if (!existsSync(src)) {
@@ -72,7 +72,7 @@ function copyItem(item) {
72
72
  return;
73
73
  }
74
74
  if (item.additiveSkills) {
75
- copyAdditiveSkills(src, dest);
75
+ copyAdditiveSkills(src, dest, skillsFilter);
76
76
  return;
77
77
  }
78
78
  if (item.additive && existsSync(dest)) {
@@ -86,7 +86,7 @@ function copyItem(item) {
86
86
  console.log(` ✓ ${item.dest}`);
87
87
  }
88
88
 
89
- function copyAdditiveSkills(srcDir, destDir) {
89
+ function copyAdditiveSkills(srcDir, destDir, skillsFilter) {
90
90
  const skillsSrc = resolve(srcDir, "skills");
91
91
  const skillsDest = resolve(destDir, "skills");
92
92
 
@@ -98,10 +98,20 @@ function copyAdditiveSkills(srcDir, destDir) {
98
98
  mkdirSync(destDir, { recursive: true });
99
99
  mkdirSync(skillsDest, { recursive: true });
100
100
 
101
- const skills = readdirSync(skillsSrc, { withFileTypes: true })
101
+ let skills = readdirSync(skillsSrc, { withFileTypes: true })
102
102
  .filter((d) => d.isDirectory())
103
103
  .map((d) => d.name);
104
104
 
105
+ // If a filter is provided, only copy selected skills
106
+ if (skillsFilter) {
107
+ skills = skills.filter((s) => skillsFilter.includes(s));
108
+ }
109
+
110
+ if (skills.length === 0) {
111
+ console.log(` → .agents/skills/ — no skills selected, skipping.`);
112
+ return;
113
+ }
114
+
105
115
  let added = 0;
106
116
  let skipped = 0;
107
117
  for (const skill of skills) {
@@ -114,14 +124,15 @@ function copyAdditiveSkills(srcDir, destDir) {
114
124
  }
115
125
  }
116
126
 
117
- console.log(` ✓ .agents/skills/ ${added} added, ${skipped} already existed`);
127
+ const filterNote = skillsFilter ? ` (${skills.length} selected)` : "";
128
+ console.log(` ✓ .agents/skills/ — ${added} added, ${skipped} already existed${filterNote}`);
118
129
  }
119
130
 
120
- function installCategory(name) {
131
+ function installCategory(name, skillsFilter) {
121
132
  const cat = CATEGORIES[name];
122
133
  console.log(`\n── Installing ${cat.label.split(" (")[0]} ──`);
123
134
  for (const item of cat.items) {
124
- copyItem(item);
135
+ copyItem(item, skillsFilter);
125
136
  }
126
137
  }
127
138
 
@@ -153,6 +164,39 @@ async function main() {
153
164
  ? Object.keys(CATEGORIES)
154
165
  : chosen;
155
166
 
167
+ // ── Skill selection ────────────────────────────────────────────
168
+ let skillsFilter = null; // null = install all skills (no filter)
169
+ if (toInstall.includes("skills")) {
170
+ const skillsSrc = resolve(FILES, ".agents", "skills");
171
+ const availableSkills = readdirSync(skillsSrc, { withFileTypes: true })
172
+ .filter((d) => d.isDirectory())
173
+ .map((d) => d.name)
174
+ .sort();
175
+
176
+ console.log(""); // spacer
177
+ const chosenSkills = await checkbox({
178
+ message: "Select skills to install:",
179
+ choices: [
180
+ { name: "All", value: "all", isAll: true },
181
+ new Separator(),
182
+ ...availableSkills.map((s) => ({ name: s, value: s })),
183
+ ],
184
+ });
185
+
186
+ if (chosenSkills.length === 0) {
187
+ // Remove skills from install list
188
+ toInstall.splice(toInstall.indexOf("skills"), 1);
189
+ if (toInstall.length === 0) {
190
+ console.log("Nothing selected. Aborted.");
191
+ process.exit(0);
192
+ }
193
+ } else if (chosenSkills.includes("all")) {
194
+ skillsFilter = null; // all skills
195
+ } else {
196
+ skillsFilter = chosenSkills;
197
+ }
198
+ }
199
+
156
200
  const labels = toInstall.map((n) => CATEGORIES[n].label.split(" (")[0]).join(", ");
157
201
  const ok = await confirm({
158
202
  message: `Install ${labels} into ${CWD}?`,
@@ -164,7 +208,7 @@ async function main() {
164
208
  }
165
209
 
166
210
  for (const name of toInstall) {
167
- installCategory(name);
211
+ installCategory(name, skillsFilter);
168
212
  }
169
213
 
170
214
  console.log("\n✔ Done.\n");
@@ -0,0 +1,361 @@
1
+ // ── reactive-checkbox ───────────────────────────────────────────────
2
+ // Custom @inquirer/core prompt that wraps the standard checkbox
3
+ // behavior but adds a reactive "All" choice:
4
+ //
5
+ // • Space on "All" → toggles all other choices to match
6
+ // • Space on any other while "All" is checked → unchecks "All"
7
+ // • Manually checking all others → auto-checks "All"
8
+ // • 'a' (toggle all) updates "All" accordingly
9
+ // • 'i' (invert) updates "All" accordingly
10
+ //
11
+ // Matches the locally installed @inquirer/checkbox v2.5.0 API
12
+ // (yoctocolors-cjs, ansi-escapes, theme v2 structure).
13
+ // ────────────────────────────────────────────────────────────────────
14
+
15
+ import {
16
+ createPrompt,
17
+ useState,
18
+ useKeypress,
19
+ usePrefix,
20
+ usePagination,
21
+ useMemo,
22
+ useRef,
23
+ makeTheme,
24
+ isUpKey,
25
+ isDownKey,
26
+ isSpaceKey,
27
+ isNumberKey,
28
+ isEnterKey,
29
+ ValidationError,
30
+ Separator,
31
+ } from "@inquirer/core";
32
+ import pc from "yoctocolors-cjs";
33
+ import figures from "@inquirer/figures";
34
+ import ansiEscapes from "ansi-escapes";
35
+
36
+ // ── Theme (mirrors @inquirer/checkbox v2.5.0 defaults) ─────────────
37
+
38
+ const checkboxTheme = {
39
+ icon: {
40
+ checked: pc.green(figures.circleFilled),
41
+ unchecked: figures.circle,
42
+ cursor: figures.pointer,
43
+ },
44
+ style: {
45
+ disabledChoice: (text) => pc.dim(`- ${text}`),
46
+ renderSelectedChoices: (selectedChoices) =>
47
+ selectedChoices.map((choice) => choice.short).join(", "),
48
+ description: (text) => pc.cyan(text),
49
+ },
50
+ helpMode: "auto",
51
+ };
52
+
53
+ // ── "All" helpers ───────────────────────────────────────────────────
54
+
55
+ function findAllIndex(items) {
56
+ return items.findIndex((item) => !Separator.isSeparator(item) && item.isAll);
57
+ }
58
+
59
+ /**
60
+ * Reconcile "All" after individual toggles.
61
+ * If every non-All selectable item is checked → "All" becomes checked.
62
+ * Otherwise → "All" becomes unchecked.
63
+ */
64
+ function reconcileAll(items) {
65
+ const allIdx = findAllIndex(items);
66
+ if (allIdx === -1) return items;
67
+
68
+ const nonAllSelectable = items.filter(
69
+ (item, i) => i !== allIdx && isSelectable(item),
70
+ );
71
+ const allChecked = nonAllSelectable.length > 0 &&
72
+ nonAllSelectable.every((item) => item.checked);
73
+
74
+ const allItem = items[allIdx];
75
+ if (allItem.checked === allChecked) return items;
76
+
77
+ const updated = [...items];
78
+ updated[allIdx] = { ...allItem, checked: allChecked };
79
+ return updated;
80
+ }
81
+
82
+ // ── Helpers (from @inquirer/checkbox v2.5.0) ────────────────────────
83
+
84
+ function isSelectable(item) {
85
+ return !Separator.isSeparator(item) && !item.disabled;
86
+ }
87
+
88
+ function toggle(item) {
89
+ return isSelectable(item) ? { ...item, checked: !item.checked } : item;
90
+ }
91
+
92
+ function check(checked) {
93
+ return function (item) {
94
+ return isSelectable(item) ? { ...item, checked } : item;
95
+ };
96
+ }
97
+
98
+ function normalizeChoices(choices) {
99
+ return choices.map((choice) => {
100
+ if (Separator.isSeparator(choice)) return choice;
101
+
102
+ if (typeof choice === "string") {
103
+ return {
104
+ value: choice,
105
+ name: choice,
106
+ short: choice,
107
+ disabled: false,
108
+ checked: false,
109
+ isAll: false,
110
+ };
111
+ }
112
+
113
+ const name = choice.name ?? String(choice.value);
114
+ return {
115
+ value: choice.value,
116
+ name,
117
+ short: choice.short ?? name,
118
+ description: choice.description,
119
+ disabled: choice.disabled ?? false,
120
+ checked: choice.checked ?? false,
121
+ isAll: choice.isAll ?? false,
122
+ };
123
+ });
124
+ }
125
+
126
+ // ── The prompt ──────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * A checkbox prompt with an optional reactive "All" choice.
130
+ *
131
+ * Usage is identical to @inquirer/checkbox, with one addition:
132
+ * any choice with `{ isAll: true }` acts as a master selector.
133
+ *
134
+ * @example
135
+ * const answer = await reactiveCheckbox({
136
+ * message: "Select categories to install:",
137
+ * choices: [
138
+ * { name: "All", value: "all", isAll: true },
139
+ * new Separator(),
140
+ * { name: "Skills", value: "skills" },
141
+ * { name: "OpenCode", value: "opencode" },
142
+ * { name: "Pi", value: "pi" },
143
+ * ],
144
+ * });
145
+ */
146
+ export default createPrompt((config, done) => {
147
+ const {
148
+ instructions,
149
+ pageSize = 7,
150
+ loop = true,
151
+ required,
152
+ validate = () => true,
153
+ } = config;
154
+ const theme = makeTheme(checkboxTheme, config.theme);
155
+ const prefix = usePrefix({ theme });
156
+ const firstRender = useRef(true);
157
+ const [status, setStatus] = useState("pending");
158
+ const [items, setItems] = useState(() => normalizeChoices(config.choices));
159
+
160
+ const bounds = useMemo(() => {
161
+ const first = items.findIndex(isSelectable);
162
+ const last = items.findLastIndex(isSelectable);
163
+
164
+ if (first < 0) {
165
+ throw new ValidationError(
166
+ "[checkbox prompt] No selectable choices. All choices are disabled.",
167
+ );
168
+ }
169
+
170
+ return { first, last };
171
+ }, [items]);
172
+
173
+ const [active, setActive] = useState(bounds.first);
174
+ const [showHelpTip, setShowHelpTip] = useState(true);
175
+ const [errorMsg, setError] = useState();
176
+
177
+ useKeypress(async (key) => {
178
+ if (isEnterKey(key)) {
179
+ const selection = items.filter((item) =>
180
+ !Separator.isSeparator(item) && item.checked,
181
+ );
182
+ const isValid = await validate([...selection]);
183
+ // "required" only counts non-All selectable items
184
+ if (required && !items.some((item) => isSelectable(item) && !item.isAll && item.checked)) {
185
+ setError("At least one choice must be selected");
186
+ } else if (isValid === true) {
187
+ setStatus("done");
188
+ done(selection.map((choice) => choice.value));
189
+ } else {
190
+ setError(isValid || "You must select a valid value");
191
+ }
192
+ } else if (isUpKey(key) || isDownKey(key)) {
193
+ if (
194
+ loop ||
195
+ (isUpKey(key) && active !== bounds.first) ||
196
+ (isDownKey(key) && active !== bounds.last)
197
+ ) {
198
+ const offset = isUpKey(key) ? -1 : 1;
199
+ let next = active;
200
+ do {
201
+ next = (next + offset + items.length) % items.length;
202
+ } while (!isSelectable(items[next]));
203
+ setActive(next);
204
+ }
205
+ if (errorMsg) setError(undefined);
206
+ } else if (isSpaceKey(key)) {
207
+ setShowHelpTip(false);
208
+ const activeItem = items[active];
209
+
210
+ if (activeItem && !Separator.isSeparator(activeItem) && activeItem.isAll) {
211
+ // ── "All" toggled ────────────────────────────────────────
212
+ setError(undefined);
213
+ const newChecked = !activeItem.checked;
214
+ const allIdx = findAllIndex(items);
215
+ let updated = items.map((item, i) => {
216
+ if (i === allIdx) return { ...item, checked: newChecked };
217
+ if (isSelectable(item)) return { ...item, checked: newChecked };
218
+ return item;
219
+ });
220
+ setItems(updated);
221
+ } else {
222
+ // ── Individual item toggled ──────────────────────────────
223
+ setError(undefined);
224
+ const newItems = items.map((choice, i) =>
225
+ i === active ? toggle(choice) : choice,
226
+ );
227
+ setItems(reconcileAll(newItems));
228
+ }
229
+ } else if (key.name === "a") {
230
+ // Toggle all non-All items
231
+ const nonAll = items.filter(
232
+ (item) => isSelectable(item) && !item.isAll,
233
+ );
234
+ const selectAll = nonAll.some((item) => !item.checked);
235
+
236
+ const allIdx = findAllIndex(items);
237
+ let updated = items.map((item, i) => {
238
+ if (i === allIdx) return { ...item, checked: selectAll };
239
+ if (isSelectable(item) && !item.isAll) {
240
+ return { ...item, checked: selectAll };
241
+ }
242
+ return item;
243
+ });
244
+ setItems(updated);
245
+ } else if (key.name === "i") {
246
+ // Invert all non-All items, then reconcile All
247
+ const allIdx = findAllIndex(items);
248
+ let updated = items.map((item, i) => {
249
+ if (i === allIdx || !isSelectable(item)) return item;
250
+ return toggle(item);
251
+ });
252
+ updated = reconcileAll(updated);
253
+ setItems(updated);
254
+ } else if (isNumberKey(key)) {
255
+ const position = Number(key.name) - 1;
256
+ const item = items[position];
257
+ if (item != null && isSelectable(item)) {
258
+ setActive(position);
259
+ setShowHelpTip(false);
260
+ if (item.isAll) {
261
+ const newChecked = !item.checked;
262
+ const allIdx = findAllIndex(items);
263
+ let updated = items.map((item, i) => {
264
+ if (i === allIdx) return { ...item, checked: newChecked };
265
+ if (isSelectable(item)) return { ...item, checked: newChecked };
266
+ return item;
267
+ });
268
+ setItems(updated);
269
+ } else {
270
+ const newItems = items.map((choice, i) =>
271
+ i === position ? toggle(choice) : choice,
272
+ );
273
+ setItems(reconcileAll(newItems));
274
+ }
275
+ }
276
+ }
277
+ });
278
+
279
+ const message = theme.style.message(config.message);
280
+
281
+ let description;
282
+ const page = usePagination({
283
+ items,
284
+ active,
285
+ renderItem({ item, isActive }) {
286
+ if (Separator.isSeparator(item)) {
287
+ return ` ${item.separator}`;
288
+ }
289
+
290
+ if (item.disabled) {
291
+ const disabledLabel =
292
+ typeof item.disabled === "string" ? item.disabled : "(disabled)";
293
+ return theme.style.disabledChoice(`${item.name} ${disabledLabel}`);
294
+ }
295
+
296
+ if (isActive) {
297
+ description = item.description;
298
+ }
299
+
300
+ const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
301
+ const color = isActive ? theme.style.highlight : (x) => x;
302
+ const cursor = isActive ? theme.icon.cursor : " ";
303
+ return color(`${cursor}${checkbox} ${item.name}`);
304
+ },
305
+ pageSize,
306
+ loop,
307
+ });
308
+
309
+ if (status === "done") {
310
+ const selection = items.filter(
311
+ (item) => !Separator.isSeparator(item) && item.checked,
312
+ );
313
+ const answer = theme.style.answer(
314
+ theme.style.renderSelectedChoices(selection, items),
315
+ );
316
+ return `${prefix} ${message} ${answer}`;
317
+ }
318
+
319
+ let helpTipTop = "";
320
+ let helpTipBottom = "";
321
+ if (
322
+ theme.helpMode === "always" ||
323
+ (theme.helpMode === "auto" &&
324
+ showHelpTip &&
325
+ (instructions === undefined || instructions))
326
+ ) {
327
+ if (typeof instructions === "string") {
328
+ helpTipTop = instructions;
329
+ } else {
330
+ const keys = [
331
+ `${theme.style.key("space")} to select`,
332
+ `${theme.style.key("a")} to toggle all`,
333
+ `${theme.style.key("i")} to invert selection`,
334
+ `and ${theme.style.key("enter")} to proceed`,
335
+ ];
336
+ helpTipTop = ` (Press ${keys.join(", ")})`;
337
+ }
338
+
339
+ if (
340
+ items.length > pageSize &&
341
+ (theme.helpMode === "always" ||
342
+ (theme.helpMode === "auto" && firstRender.current))
343
+ ) {
344
+ helpTipBottom = `\n${theme.style.help("(Use arrow keys to reveal more choices)")}`;
345
+ firstRender.current = false;
346
+ }
347
+ }
348
+
349
+ const choiceDescription = description
350
+ ? `\n${theme.style.description(description)}`
351
+ : "";
352
+
353
+ let error = "";
354
+ if (errorMsg) {
355
+ error = `\n${theme.style.error(errorMsg)}`;
356
+ }
357
+
358
+ return `${prefix} ${message}${helpTipTop}\n${page}${helpTipBottom}${choiceDescription}${error}${ansiEscapes.cursorHide}`;
359
+ });
360
+
361
+ export { Separator };
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@b0tts/template-dev-installer",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "Interactive installer for the DevelopmentTemplate — pick Skills, OpenCode, Pi, or install everything at once.",
6
6
  "bin": {
7
7
  "template-dev-installer": "bin/cli.mjs"
8
8
  },
9
9
  "files": [
10
- "bin/cli.mjs",
10
+ "bin/",
11
11
  "files/"
12
12
  ],
13
13
  "dependencies": {