@lidtop/loadout 0.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/dist/picker.js ADDED
@@ -0,0 +1,569 @@
1
+ import { createPrompt, useEffect, useKeypress, useState, isEnterKey, isSpaceKey, } from '@inquirer/core';
2
+ import { stripVTControlCharacters, styleText } from 'node:util';
3
+ import path from 'node:path';
4
+ import stringWidth from 'string-width';
5
+ import { resolveKits, reasons } from './resolve.js';
6
+ import { kitSource } from './schema.js';
7
+ import { availableUpdates, hasUpdate } from './updates.js';
8
+ import { prepareInput } from './terminal.js';
9
+ import { providerDescriptions } from './curated.js';
10
+ const accent = (value) => styleText('cyan', value);
11
+ const muted = (value) => styleText('dim', value);
12
+ const bold = (value) => styleText('bold', value);
13
+ const clean = (value) => stripVTControlCharacters(value).replace(/[\x00-\x1f\x7f]/g, ' ');
14
+ function fit(value, width) {
15
+ const text = clean(value);
16
+ if (stringWidth(text) <= width)
17
+ return text;
18
+ let result = '';
19
+ for (const character of text) {
20
+ if (stringWidth(result + character) > width - 1)
21
+ break;
22
+ result += character;
23
+ }
24
+ return width > 0 ? `${result}…` : '';
25
+ }
26
+ function wordmark(width, rows) {
27
+ if (width < 43 || rows < 20)
28
+ return ['', ` ${accent(bold('LOADOUT'))}`, ''];
29
+ return [
30
+ '',
31
+ accent(' █ █▀▀█ █▀▀█ █▀▀▄ █▀▀█ █ █ ▀▀█▀▀'),
32
+ accent(' █ █ █ █▄▄█ █ █ █ █ █ █ █'),
33
+ accent(' █▄▄█ █▄▄█ █ █ █▄▄▀ █▄▄█ ▀▄▄▀ █'),
34
+ '',
35
+ ];
36
+ }
37
+ const repositorySections = ['Kits', 'Browse', 'Installed'];
38
+ const providerFor = (kit) => kit.origin === 'bundled' ? 'loadout' : kit.external?.repo;
39
+ const providerPrefixes = {
40
+ loadout: 'loadout-',
41
+ 'mattpocock/skills': 'matt-pocock-',
42
+ 'anthropics/skills': 'anthropic-',
43
+ };
44
+ const emptyState = () => ({
45
+ schemaVersion: 1,
46
+ selected: [],
47
+ answers: {},
48
+ });
49
+ const renderPicker = createPrompt((config, done) => {
50
+ const [targets, setTargets] = useState(config.targets);
51
+ const [targetIndex, setTargetIndex] = useState(config.initial ?? 0);
52
+ const [visited, setVisited] = useState([config.initial ?? 0]);
53
+ const [selections, setSelections] = useState(config.targets.map((target) => (target.state?.selected ?? []).filter((id) => target.catalog?.kits.has(id) &&
54
+ target.catalog.kits.get(id)?.ready !== false)));
55
+ const [pending, setPending] = useState(undefined);
56
+ const [pendingSwitch, setPendingSwitch] = useState(undefined);
57
+ const [notice, setNotice] = useState('');
58
+ const target = targets[targetIndex];
59
+ const sections = target.global
60
+ ? ['Browse', 'Installed']
61
+ : repositorySections;
62
+ const catalog = target.catalog ?? {
63
+ root: target.root,
64
+ kits: new Map(),
65
+ };
66
+ const selected = selections[targetIndex];
67
+ const hasSelectionChanges = JSON.stringify([...selected].sort()) !==
68
+ JSON.stringify([...(target.state?.selected ?? [])].sort());
69
+ const setSelected = (value) => setSelections(selections.map((ids, index) => (index === targetIndex ? value : ids)));
70
+ const [section, setSection] = useState(target.global ? 'Browse' : 'Kits');
71
+ const [scopeFocused, setScopeFocused] = useState(false);
72
+ const [provider, setProvider] = useState(undefined);
73
+ const [query, setQuery] = useState('');
74
+ const [active, setActive] = useState(0);
75
+ const [finished, setFinished] = useState(false);
76
+ const [size, setSize] = useState({
77
+ columns: process.stdout.columns || 80,
78
+ rows: process.stdout.rows || 24,
79
+ });
80
+ useEffect(() => {
81
+ const resize = () => setSize({
82
+ columns: process.stdout.columns || 80,
83
+ rows: process.stdout.rows || 24,
84
+ });
85
+ process.stdout.on('resize', resize);
86
+ return () => {
87
+ process.stdout.off('resize', resize);
88
+ };
89
+ }, []);
90
+ const width = Math.max(12, (config.columns ?? size.columns) - 2);
91
+ const height = config.rows ?? size.rows;
92
+ const enabled = resolveKits(catalog, selected);
93
+ const enabledSet = new Set(enabled);
94
+ const installedSet = new Set(resolveKits(catalog, (target.state?.selected ?? []).filter((id) => catalog.kits.has(id) && catalog.kits.get(id)?.ready !== false)));
95
+ const willUninstall = (id) => installedSet.has(id) && !enabledSet.has(id);
96
+ const requiredCount = enabled.length - selected.length;
97
+ const all = [...catalog.kits.values()];
98
+ const matches = (kit) => `${kit.id} ${kit.description} ${kitSource(kit)} ${kit.external?.repo ?? ''} ${kit.external?.skills.join(' ') ?? ''}`
99
+ .toLowerCase()
100
+ .includes(query.toLowerCase());
101
+ const browsingProviders = section === 'Browse' && !provider;
102
+ const displayName = (id) => {
103
+ const kit = catalog.kits.get(id);
104
+ if (section !== 'Browse' ||
105
+ !provider ||
106
+ !kit ||
107
+ providerFor(kit) !== provider)
108
+ return id;
109
+ const prefix = providerPrefixes[provider];
110
+ return prefix && id.startsWith(prefix) && id.length > prefix.length
111
+ ? id.slice(prefix.length)
112
+ : id;
113
+ };
114
+ let entries;
115
+ if (browsingProviders) {
116
+ const providers = new Map();
117
+ for (const kit of all) {
118
+ const name = providerFor(kit);
119
+ if (!name)
120
+ continue;
121
+ const kits = providers.get(name) ?? [];
122
+ kits.push(kit);
123
+ providers.set(name, kits);
124
+ }
125
+ entries = [...providers]
126
+ .sort(([a], [b]) => a === 'loadout' ? -1 : b === 'loadout' ? 1 : a.localeCompare(b))
127
+ .flatMap(([id, kits]) => {
128
+ const matching = kits.filter(matches);
129
+ if (!matching.length)
130
+ return [];
131
+ const count = kits.filter((kit) => enabledSet.has(kit.id)).length;
132
+ const origins = new Set(kits.map((kit) => kit.origin));
133
+ const description = id === 'loadout'
134
+ ? 'Included with Loadout'
135
+ : origins.has('curated') && providerDescriptions[id]
136
+ ? providerDescriptions[id]
137
+ : origins.size > 1
138
+ ? 'Curated and repository sources'
139
+ : origins.has('curated')
140
+ ? 'Curated kits'
141
+ : 'Repository sources';
142
+ return [
143
+ {
144
+ id,
145
+ count: matching.length,
146
+ description,
147
+ selectedCount: count,
148
+ downloadedCount: kits.filter((kit) => !!kit.pinned).length,
149
+ },
150
+ ];
151
+ });
152
+ }
153
+ else {
154
+ entries = all
155
+ .filter((kit) => {
156
+ if (!matches(kit))
157
+ return false;
158
+ if (section === 'Kits')
159
+ return !providerFor(kit);
160
+ if (section === 'Installed')
161
+ return (!!providerFor(kit) &&
162
+ (!!kit.pinned ||
163
+ enabledSet.has(kit.id) ||
164
+ installedSet.has(kit.id)));
165
+ return providerFor(kit) === provider;
166
+ })
167
+ .sort((a, b) => Number(a.ready === false) - Number(b.ready === false))
168
+ .map((kit) => ({ id: kit.id, description: kit.description, kit }));
169
+ }
170
+ entries.push({
171
+ id: '@continue',
172
+ action: 'continue',
173
+ description: 'Review selections and apply',
174
+ });
175
+ const cursor = Math.min(active, Math.max(0, entries.length - 1));
176
+ const focused = entries[cursor];
177
+ useKeypress((key, rl) => {
178
+ const reset = () => {
179
+ setQuery('');
180
+ setActive(0);
181
+ rl.clearLine(0);
182
+ };
183
+ const restoreInput = () => {
184
+ rl.clearLine(0);
185
+ rl.write(query);
186
+ };
187
+ const switchTarget = (index) => {
188
+ setScopeFocused(false);
189
+ setTargetIndex(index);
190
+ setSection(targets[index].global ? 'Browse' : 'Kits');
191
+ setVisited([...new Set([...visited, index])]);
192
+ setProvider(undefined);
193
+ setNotice('');
194
+ reset();
195
+ };
196
+ if (pendingSwitch !== undefined) {
197
+ if (key.name === 'y') {
198
+ switchTarget(pendingSwitch);
199
+ setPendingSwitch(undefined);
200
+ }
201
+ else if (key.name === 'n' ||
202
+ key.name === 'escape' ||
203
+ isEnterKey(key)) {
204
+ setPendingSwitch(undefined);
205
+ restoreInput();
206
+ }
207
+ return;
208
+ }
209
+ if (pending !== undefined) {
210
+ if (key.name === 'y' && config.initialize) {
211
+ try {
212
+ const initialized = config.initialize(targets[pending]);
213
+ if (!initialized.catalog)
214
+ throw new Error(initialized.error ??
215
+ 'Initialization did not produce a catalog.');
216
+ setTargets(targets.map((item, index) => index === pending ? initialized : item));
217
+ setSelections(selections.map((ids, index) => index === pending ? (initialized.state?.selected ?? []) : ids));
218
+ switchTarget(pending);
219
+ }
220
+ catch (error) {
221
+ setNotice(error.message);
222
+ }
223
+ setPending(undefined);
224
+ }
225
+ else if (key.name === 'n' ||
226
+ key.name === 'escape' ||
227
+ isEnterKey(key)) {
228
+ setPending(undefined);
229
+ }
230
+ restoreInput();
231
+ return;
232
+ }
233
+ if (scopeFocused && (isEnterKey(key) || isSpaceKey(key))) {
234
+ setScopeFocused(false);
235
+ const next = (targetIndex + 1) % targets.length;
236
+ const other = targets[next];
237
+ if (other.error)
238
+ setNotice(other.error);
239
+ else if (!other.catalog) {
240
+ setPending(next);
241
+ reset();
242
+ }
243
+ else if (hasSelectionChanges) {
244
+ setPendingSwitch(next);
245
+ }
246
+ else
247
+ switchTarget(next);
248
+ rl.clearLine(0);
249
+ return;
250
+ }
251
+ if (['tab', 'left', 'right'].includes(key.name)) {
252
+ const index = scopeFocused
253
+ ? sections.length
254
+ : sections.indexOf(section);
255
+ const count = sections.length + (targets.length > 1 ? 1 : 0);
256
+ const backwards = key.name === 'left' || (key.name === 'tab' && key.shift);
257
+ const next = (index + (backwards ? -1 : 1) + count) % count;
258
+ setScopeFocused(next === sections.length);
259
+ if (next !== sections.length) {
260
+ setSection(sections[next]);
261
+ setProvider(undefined);
262
+ reset();
263
+ }
264
+ else
265
+ restoreInput();
266
+ }
267
+ else if (key.name === 'up' || key.name === 'down') {
268
+ setScopeFocused(false);
269
+ if (entries.length)
270
+ setActive((cursor + (key.name === 'up' ? -1 : 1) + entries.length) %
271
+ entries.length);
272
+ restoreInput();
273
+ }
274
+ else if (isEnterKey(key) || isSpaceKey(key)) {
275
+ if (focused?.action === 'continue') {
276
+ setFinished(true);
277
+ done(targets.flatMap((item, index) => {
278
+ if (!item.catalog || !visited.includes(index))
279
+ return [];
280
+ const ids = [...selections[index]].sort();
281
+ const saved = item.state ?? emptyState();
282
+ if (index !== targetIndex &&
283
+ JSON.stringify(ids) ===
284
+ JSON.stringify([...saved.selected].sort()))
285
+ return [];
286
+ return [{ target: item, state: { ...saved, selected: ids } }];
287
+ }));
288
+ }
289
+ else if (browsingProviders && focused) {
290
+ setProvider(focused.id);
291
+ setActive(0);
292
+ }
293
+ else if (focused?.kit && focused.kit.ready !== false) {
294
+ setSelected(selected.includes(focused.id)
295
+ ? selected.filter((id) => id !== focused.id)
296
+ : [...selected, focused.id]);
297
+ setActive(cursor);
298
+ }
299
+ rl.clearLine(0);
300
+ rl.write(query);
301
+ }
302
+ else if (key.name === 'escape') {
303
+ if (scopeFocused) {
304
+ setScopeFocused(false);
305
+ restoreInput();
306
+ }
307
+ else {
308
+ if (!query && provider)
309
+ setProvider(undefined);
310
+ reset();
311
+ }
312
+ }
313
+ else if (!['left', 'right', 'home', 'end'].includes(key.name)) {
314
+ setScopeFocused(false);
315
+ setQuery(clean(rl.line));
316
+ setActive(0);
317
+ }
318
+ });
319
+ const header = wordmark(width, height);
320
+ const other = targets.length > 1
321
+ ? targets[(targetIndex + 1) % targets.length]
322
+ : undefined;
323
+ const switchLabel = other
324
+ ? `[ Go to ${other.label}${other.error ? ' !' : ''} ]`
325
+ : '';
326
+ const scopeLabel = width >= 60 ? `${target.label} · ${target.root}` : target.label;
327
+ const scopeText = fit(scopeLabel, width - switchLabel.length - 4);
328
+ const scopeLines = targets.length > 1 || target.global
329
+ ? [
330
+ ` ${bold(scopeText)}${' '.repeat(Math.max(2, width - stringWidth(scopeText) - switchLabel.length - 2))}${scopeFocused ? accent(bold(switchLabel)) : muted(switchLabel)}`,
331
+ ...(width < 60 ? [` ${muted(fit(target.root, width - 2))}`] : []),
332
+ ]
333
+ : [];
334
+ const selectionWarning = hasSelectionChanges
335
+ ? [
336
+ ` ${fit(`Unapplied changes in ${target.label}.`, width - 2)}`,
337
+ ' Selections stay in this session.',
338
+ ]
339
+ : [];
340
+ if (pendingSwitch !== undefined)
341
+ return [
342
+ ...header,
343
+ ...scopeLines,
344
+ '',
345
+ ` ${bold(fit(`Switch to ${targets[pendingSwitch].label}?`, width - 2))}`,
346
+ ...selectionWarning,
347
+ '',
348
+ ` ${accent('[Enter/Esc]')} Stay ${accent('[y]')} Switch`,
349
+ '\u001b[?25l',
350
+ ].join('\n');
351
+ if (pending !== undefined) {
352
+ const destination = targets[pending];
353
+ const pathWidth = width - 9;
354
+ const catalogPath = path.join(destination.root, '.loadout');
355
+ const displayPath = stringWidth(clean(catalogPath)) <= pathWidth
356
+ ? clean(catalogPath)
357
+ : destination.global
358
+ ? '~/.loadout'
359
+ : `${fit(destination.root, pathWidth - 9)}/.loadout`;
360
+ return [
361
+ ...header,
362
+ ...scopeLines,
363
+ '',
364
+ ` ${bold(fit(`Set up ${destination.label} and switch?`, width - 2))}`,
365
+ ` ${fit(`Create ${displayPath}`, width - 2)}`,
366
+ ...selectionWarning,
367
+ '',
368
+ ` ${accent('[Enter/Esc]')} Cancel ${accent('[y]')} Set up`,
369
+ '\u001b[?25l',
370
+ ].join('\n');
371
+ }
372
+ if (finished)
373
+ return [
374
+ ...header,
375
+ ` ${accent('✓')} ${selected.length} selected${requiredCount ? muted(` · ${requiredCount} required`) : ''}\n`,
376
+ ].join('\n');
377
+ const updates = availableUpdates(catalog).length;
378
+ const counts = `${selected.length} selected${requiredCount ? ` · ${requiredCount} required` : ''}`;
379
+ const tabs = width >= 34
380
+ ? ` ${sections.map((name) => (name === section && !scopeFocused ? accent(bold(`[${name}]`)) : muted(name))).join(' ')}`
381
+ : ` ${scopeFocused ? muted(section) : accent(bold(`[${section}]`))} ${muted('←→/tab')}`;
382
+ const filter = ` ${accent('/')} ${query ? `${fit(query, width - 6)}${accent('▏')}` : muted(browsingProviders ? 'Search providers or kits' : 'Search kits')}`;
383
+ const rule = ` ${muted('─'.repeat(width - 2))}`;
384
+ const detailed = height >= 20;
385
+ const hints = scopeFocused
386
+ ? [
387
+ `space/enter ${other?.error ? 'details' : other?.catalog ? 'switch' : 'setup'}`,
388
+ '←→/tab move',
389
+ 'esc back',
390
+ ]
391
+ : [
392
+ '↑↓ move',
393
+ `space/enter ${focused?.action ? 'select' : browsingProviders ? 'open' : 'toggle'}`,
394
+ '←→/tab switch',
395
+ `esc ${provider ? 'back' : 'clear'}`,
396
+ ];
397
+ const helpLines = [];
398
+ const separator = width >= 100 ? ' ' : ' · ';
399
+ for (const hint of hints) {
400
+ const previous = helpLines.at(-1);
401
+ const combined = previous ? `${previous}${separator}${hint}` : hint;
402
+ if (previous && stringWidth(combined) <= width - 2)
403
+ helpLines[helpLines.length - 1] = combined;
404
+ else
405
+ helpLines.push(fit(hint, width - 2));
406
+ }
407
+ const help = helpLines.map((line) => ` ${muted(line)}`);
408
+ if (scopeFocused && other) {
409
+ const action = other.error
410
+ ? `${other.label} is unavailable`
411
+ : `${other.catalog ? 'Switch to' : 'Set up'} ${other.label}`;
412
+ return [
413
+ ...header,
414
+ ...scopeLines,
415
+ '',
416
+ tabs,
417
+ rule,
418
+ ` ${bold(fit(action, width - 2))}`,
419
+ ...(other.error ? [` ${fit(other.error, width - 2)}`] : []),
420
+ ` ${muted(fit(other.root, width - 2))}`,
421
+ rule,
422
+ ...help,
423
+ '\u001b[?25l',
424
+ ].join('\n');
425
+ }
426
+ const beforeList = [
427
+ ...header,
428
+ ...scopeLines,
429
+ ...(scopeLines.length && detailed ? [''] : []),
430
+ tabs,
431
+ ...(provider ? [` ${muted(fit(provider, width - 2))}`] : []),
432
+ ...(detailed ? [''] : []),
433
+ rule,
434
+ filter,
435
+ ...(detailed ? [''] : []),
436
+ ];
437
+ const summary = fit(`${counts}${updates ? ` · ${updates} updates` : ''}`, width - 6);
438
+ const bottomRule = ` ${muted(`${'─'.repeat(Math.max(1, width - stringWidth(summary) - 6))} ${summary} ─`)}`;
439
+ // Border, detail, spacing, keyboard help, and cursor-control line.
440
+ const footerHeight = 5 + help.length;
441
+ const pageSize = Math.max(1, Math.min(8, Math.floor((height - beforeList.length - footerHeight) / (detailed ? 2 : 1))));
442
+ const listEntries = entries.filter((row) => !row.action);
443
+ const listCursor = Math.min(cursor, Math.max(0, listEntries.length - 1));
444
+ const start = Math.max(0, Math.min(listCursor - Math.floor(pageSize / 2), listEntries.length - pageSize));
445
+ const page = listEntries.slice(start, start + pageSize);
446
+ const lines = [];
447
+ for (const row of page) {
448
+ const focus = row.id === focused?.id;
449
+ const explicit = selected.includes(row.id);
450
+ const required = enabledSet.has(row.id) && !explicit;
451
+ const providerStatus = [
452
+ row.selectedCount
453
+ ? `${row.selectedCount} ${width >= 76 ? 'selected' : 'sel'}`
454
+ : '',
455
+ row.downloadedCount
456
+ ? `${row.downloadedCount} ${width >= 76 ? 'downloaded' : 'dl'}`
457
+ : '',
458
+ ]
459
+ .filter(Boolean)
460
+ .join(' · ');
461
+ const label = !row.kit
462
+ ? [
463
+ width >= 76 || !providerStatus ? `${row.count} kits` : '',
464
+ providerStatus,
465
+ ]
466
+ .filter(Boolean)
467
+ .join(' · ')
468
+ : row.kit.ready === false
469
+ ? 'Needs setup'
470
+ : willUninstall(row.id)
471
+ ? 'Will uninstall'
472
+ : hasUpdate(row.kit)
473
+ ? 'update'
474
+ : explicit
475
+ ? 'selected'
476
+ : required
477
+ ? 'required'
478
+ : row.kit.pinned
479
+ ? 'saved'
480
+ : '';
481
+ const marker = !row.kit
482
+ ? accent('▸')
483
+ : explicit
484
+ ? accent('●')
485
+ : required
486
+ ? styleText('yellow', '◆')
487
+ : muted('○');
488
+ const badge = required || willUninstall(row.id) || (row.kit && hasUpdate(row.kit))
489
+ ? styleText('yellow', label)
490
+ : muted(label);
491
+ const name = fit(displayName(row.id), width - label.length - 9);
492
+ const gap = ' '.repeat(Math.max(2, width - stringWidth(name) - label.length - 6));
493
+ lines.push(` ${focus ? accent('›') : ' '} ${marker} ${row.kit?.ready === false ? muted(name) : focus ? bold(name) : name}${gap}${badge}`);
494
+ if (detailed)
495
+ lines.push(` ${muted(fit(row.kit && section === 'Installed' ? `${kitSource(row.kit)} · ${row.description}` : row.description, width - 6))}`);
496
+ }
497
+ if (!page.length) {
498
+ const empty = !catalog.kits.size
499
+ ? 'No kits in this catalog.'
500
+ : query
501
+ ? 'No matching kits. Esc to clear.'
502
+ : section === 'Kits'
503
+ ? 'No repository kits. Tab to browse.'
504
+ : section === 'Installed'
505
+ ? 'No kits installed. Tab to browse.'
506
+ : 'No external providers configured.';
507
+ lines.push(` ${muted(fit(empty, width - 2))}`);
508
+ }
509
+ const kit = focused?.kit;
510
+ const why = kit ? reasons(catalog, selected, kit.id) : [];
511
+ const detail = focused?.action
512
+ ? focused.description
513
+ : browsingProviders
514
+ ? [
515
+ focused?.selectedCount ? `${focused.selectedCount} selected` : '',
516
+ focused?.downloadedCount
517
+ ? `${focused.downloadedCount} downloaded`
518
+ : '',
519
+ ]
520
+ .filter(Boolean)
521
+ .join(' · ') || 'Choose a provider to explore its kits'
522
+ : kit?.ready === false
523
+ ? 'Edit this kit, then set ready: true in kit.yaml'
524
+ : kit && willUninstall(kit.id)
525
+ ? 'Uninstall on apply. Select again to keep.'
526
+ : kit && hasUpdate(kit)
527
+ ? `Catalog update · ${kit.pinned.ref.slice(0, 8)} → ${kit.external.ref.slice(0, 8)}`
528
+ : why.length
529
+ ? `Required by ${why.map(displayName).join(', ')}${selected.includes(kit.id) ? ' · also selected' : ' · space to keep explicitly'}`
530
+ : kit?.requires.length
531
+ ? `Requires ${kit.requires.map(displayName).join(', ')}`
532
+ : kit?.external
533
+ ? `Includes ${(kit.pinned ?? kit.external).skills.map((p) => p.split('/').at(-1)).join(', ')}`
534
+ : '';
535
+ const pagination = listEntries.length > page.length
536
+ ? `${start + 1}–${start + page.length} of ${listEntries.length}`
537
+ : '';
538
+ return [
539
+ ...beforeList,
540
+ ...lines,
541
+ bottomRule,
542
+ ` ${focused?.action ? accent('›') : ' '} ${focused?.action ? accent(bold('[ Continue ]')) : '[ Continue ]'}`,
543
+ ` ${muted(fit(notice || [pagination, detail].filter(Boolean).join(' · '), width - 2))}`,
544
+ '',
545
+ ...help,
546
+ '\u001b[?25l',
547
+ ].join('\n');
548
+ });
549
+ export function targetPicker(config, context) {
550
+ prepareInput(context?.input ?? process.stdin);
551
+ return renderPicker(config, context);
552
+ }
553
+ // Single-location entry point for consumers that only need selected IDs.
554
+ export async function kitPicker(config, context) {
555
+ const result = await targetPicker({
556
+ targets: [
557
+ {
558
+ label: 'Repository',
559
+ root: config.catalog.root,
560
+ global: false,
561
+ catalog: config.catalog,
562
+ state: { ...emptyState(), selected: config.selected },
563
+ },
564
+ ],
565
+ columns: config.columns,
566
+ rows: config.rows,
567
+ }, context);
568
+ return result[0].state.selected;
569
+ }
@@ -0,0 +1,14 @@
1
+ import { type Catalog, type State } from './schema.js';
2
+ export type FileContent = {
3
+ content: Buffer;
4
+ mode: number;
5
+ };
6
+ export type Rendered = {
7
+ files: Map<string, FileContent>;
8
+ skillRoots: Set<string>;
9
+ external?: {
10
+ before?: Buffer;
11
+ content: Buffer;
12
+ };
13
+ };
14
+ export declare function render(catalog: Catalog, state: State): Rendered;
package/dist/render.js ADDED
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { agents } from './schema.js';
4
+ import { safePath, walk, portableMode } from './fs.js';
5
+ import { resolveKits } from './resolve.js';
6
+ export function render(catalog, state) {
7
+ const files = new Map();
8
+ const skillRoots = new Set();
9
+ const sections = new Map();
10
+ for (const id of resolveKits(catalog, state.selected)) {
11
+ const kit = catalog.kits.get(id);
12
+ for (const output of kit.outputs) {
13
+ if (output.when &&
14
+ state.answers[id]?.[output.when.answer] !== output.when.equals)
15
+ continue;
16
+ const source = safePath(kit.directory, output.source);
17
+ if (output.type === 'instructions') {
18
+ const section = fs.readFileSync(source, 'utf8').trim();
19
+ sections.set(output.scope, [
20
+ ...(sections.get(output.scope) ?? []),
21
+ section,
22
+ ]);
23
+ }
24
+ else {
25
+ for (const agent of agents) {
26
+ const destination = `${agent === 'codex' ? '.agents' : '.claude'}/skills/${path.basename(source)}`;
27
+ if (skillRoots.has(destination))
28
+ throw new Error(`Output collision: multiple skills target ${destination}`);
29
+ skillRoots.add(destination);
30
+ for (const file of walk(source)) {
31
+ const src = safePath(source, file);
32
+ files.set(`${destination}/${file}`, {
33
+ content: fs.readFileSync(src),
34
+ mode: portableMode(fs.statSync(src).mode & 0o111 ? 0o755 : 0o644),
35
+ });
36
+ }
37
+ }
38
+ }
39
+ }
40
+ }
41
+ for (const [scope, content] of sections) {
42
+ const instructions = catalog.global
43
+ ? '.codex/AGENTS.md'
44
+ : path.posix.join(scope, 'AGENTS.md');
45
+ const claude = catalog.global
46
+ ? '.claude/CLAUDE.md'
47
+ : path.posix.join(scope, 'CLAUDE.md');
48
+ if (files.has(instructions) || files.has(claude))
49
+ throw new Error(`Output collision: ${scope}`);
50
+ files.set(instructions, {
51
+ content: Buffer.from(`${content.join('\n\n')}\n`),
52
+ mode: 0o644,
53
+ });
54
+ // Imports resolve beside CLAUDE.md, including within nested scopes.
55
+ files.set(claude, {
56
+ content: Buffer.from(catalog.global ? '@../.codex/AGENTS.md\n' : '@AGENTS.md\n'),
57
+ mode: 0o644,
58
+ });
59
+ }
60
+ return {
61
+ files: new Map([...files].sort(([a], [b]) => a.localeCompare(b))),
62
+ skillRoots,
63
+ };
64
+ }
@@ -0,0 +1,6 @@
1
+ import { type Answer, type Catalog, type Question, type State } from './schema.js';
2
+ export declare function resolveKits(catalog: Catalog, selected: string[]): string[];
3
+ export declare function reasons(catalog: Catalog, selected: string[], target: string): string[];
4
+ export declare function disableKits(catalog: Catalog, selected: string[], target: string, cascade: boolean): string[];
5
+ export declare function configure(catalog: Catalog, state: State, ask?: (kit: string, key: string, question: Question, saved: Answer | undefined) => Promise<Answer>): Promise<State>;
6
+ export declare function setAnswers(catalog: Catalog, state: State, values: string[]): void;