@djordje-stojanovic/sigmaskills 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/LICENSE +21 -0
  3. package/README.md +342 -0
  4. package/bin/sigmaskills.js +7 -0
  5. package/manifest.json +28 -0
  6. package/package.json +42 -0
  7. package/registry/agent-hosts.json +2404 -0
  8. package/registry/schema.json +110 -0
  9. package/registry/skill-baselines.json +4 -0
  10. package/registry/source.json +6 -0
  11. package/sigmabrief/SKILL.md +56 -0
  12. package/sigmabrief/agents/openai.yaml +12 -0
  13. package/sigmabrief/references/brief-method.md +73 -0
  14. package/sigmabrief/references/prompt-contract.md +174 -0
  15. package/sigmaperformance/SKILL.md +118 -0
  16. package/sigmaperformance/agents/openai.yaml +12 -0
  17. package/sigmaperformance/references/audit-method.md +112 -0
  18. package/sigmaperformance/references/calibration.md +45 -0
  19. package/sigmaperformance/references/report-contract.md +103 -0
  20. package/sigmareview/SKILL.md +133 -0
  21. package/sigmareview/agents/openai.yaml +12 -0
  22. package/sigmareview/references/report-contract.md +217 -0
  23. package/sigmareview/references/review-method.md +233 -0
  24. package/sigmawrite/SKILL.md +45 -0
  25. package/sigmawrite/agents/openai.yaml +12 -0
  26. package/src/adoption.js +370 -0
  27. package/src/backup.js +398 -0
  28. package/src/catalog.js +211 -0
  29. package/src/cli.js +657 -0
  30. package/src/customization.js +344 -0
  31. package/src/destinations.js +491 -0
  32. package/src/interactive.js +959 -0
  33. package/src/links.js +157 -0
  34. package/src/plan.js +429 -0
  35. package/src/prepack.js +10 -0
  36. package/src/project-lock.js +169 -0
  37. package/src/purge.js +477 -0
  38. package/src/registry/automation-ci.js +411 -0
  39. package/src/registry/automation.js +554 -0
  40. package/src/registry/diff.js +149 -0
  41. package/src/registry/normalize.js +67 -0
  42. package/src/registry/parse.js +184 -0
  43. package/src/registry/sync.js +230 -0
  44. package/src/registry/validate.js +223 -0
  45. package/src/release-ci.js +12 -0
  46. package/src/release.js +837 -0
  47. package/src/restore.js +518 -0
  48. package/src/revision.js +82 -0
  49. package/src/state.js +480 -0
  50. package/src/status.js +469 -0
  51. package/src/transaction.js +636 -0
  52. package/src/uninstall.js +647 -0
  53. package/src/update.js +815 -0
@@ -0,0 +1,959 @@
1
+ import path from 'node:path';
2
+ import readline from 'node:readline';
3
+ import { createInstallPlan, createProjectSkillClassifier, formatPlanHuman } from './plan.js';
4
+ import { createNeedsResolutionError, createUnownedConflictError, executeProjectInstall } from './transaction.js';
5
+ import { isDestinationOwned } from './state.js';
6
+ import {
7
+ UNIVERSAL_PROJECT_DESTINATION,
8
+ defaultSelectedRoots,
9
+ findDestinationConflicts,
10
+ listGlobalDestinationGroups,
11
+ listProjectDestinationGroups,
12
+ loadHostRegistry,
13
+ resolveGlobalSkillPath,
14
+ resolveHomeDir,
15
+ searchHosts,
16
+ } from './destinations.js';
17
+ import { recommendedLinkMethod } from './links.js';
18
+
19
+ export const EMBERFORGE_PALETTE = Object.freeze({
20
+ base: '#1a1714',
21
+ crust: '#0e0c0a',
22
+ text: '#e0d8cc',
23
+ gold: '#d4a564',
24
+ orange: '#cc8844',
25
+ green: '#8cb87c',
26
+ teal: '#7cb8a8',
27
+ red: '#d4645c',
28
+ });
29
+
30
+ export const EMBERFORGE_REVEAL_MS = 650;
31
+
32
+ const RESET = '\x1b[0m';
33
+ const CLEAR = '\x1b[2J\x1b[H';
34
+ const HIDE_CURSOR = '\x1b[?25l';
35
+ const SHOW_CURSOR = '\x1b[?25h';
36
+
37
+ const ANSI16_FG = Object.freeze({
38
+ '#1a1714': 30,
39
+ '#0e0c0a': 30,
40
+ '#e0d8cc': 37,
41
+ '#d4a564': 33,
42
+ '#cc8844': 33,
43
+ '#8cb87c': 32,
44
+ '#7cb8a8': 36,
45
+ '#d4645c': 31,
46
+ });
47
+
48
+ function hexToRgb(hex) {
49
+ return [1, 3, 5].map((index) => Number.parseInt(hex.slice(index, index + 2), 16));
50
+ }
51
+
52
+ function rgbTo256(red, green, blue) {
53
+ const cube = (value) => Math.round((value / 255) * 5);
54
+ return 16 + (36 * cube(red)) + (6 * cube(green)) + cube(blue);
55
+ }
56
+
57
+ function truecolorFg(hex, bold = false) {
58
+ const [red, green, blue] = hexToRgb(hex);
59
+ return `\x1b[${bold ? '1;' : ''}38;2;${red};${green};${blue}m`;
60
+ }
61
+
62
+ function truecolorBg(hex) {
63
+ const [red, green, blue] = hexToRgb(hex);
64
+ return `\x1b[48;2;${red};${green};${blue}m`;
65
+ }
66
+
67
+ function detectColorMode(stdout, env, options) {
68
+ if (
69
+ options.noColor ||
70
+ options.json ||
71
+ env.NO_COLOR !== undefined ||
72
+ env.TERM === 'dumb' ||
73
+ !stdout.isTTY
74
+ ) {
75
+ return 'plain';
76
+ }
77
+ let depth = 24;
78
+ if (typeof stdout.getColorDepth === 'function') {
79
+ depth = stdout.getColorDepth(env);
80
+ }
81
+ if (depth >= 24) return 'truecolor';
82
+ if (depth >= 8) return 'ansi256';
83
+ if (depth >= 4) return 'ansi16';
84
+ return 'plain';
85
+ }
86
+
87
+ function prefersReducedMotion(env = {}) {
88
+ const reduced = String(env.REDUCED_MOTION || '').toLowerCase();
89
+ const prefers = String(env.PREFERS_REDUCED_MOTION || '').toLowerCase();
90
+ return reduced === '1' || reduced === 'true' || prefers === 'reduce' || prefers === '1' || prefers === 'true';
91
+ }
92
+
93
+ function wrapWords(value, width) {
94
+ const max = Math.max(1, width);
95
+ if (!value) return [''];
96
+ const words = String(value).split(/\s+/);
97
+ const lines = [];
98
+ let line = '';
99
+ const flush = () => {
100
+ if (line) {
101
+ lines.push(line);
102
+ line = '';
103
+ }
104
+ };
105
+ for (const word of words) {
106
+ if (word.length > max) {
107
+ flush();
108
+ for (let index = 0; index < word.length; index += max) {
109
+ const chunk = word.slice(index, index + max);
110
+ if (index + max < word.length) lines.push(chunk);
111
+ else line = chunk;
112
+ }
113
+ continue;
114
+ }
115
+ if (!line) {
116
+ line = word;
117
+ } else if (line.length + word.length + 1 <= max) {
118
+ line += ` ${word}`;
119
+ } else {
120
+ lines.push(line);
121
+ line = word;
122
+ }
123
+ }
124
+ flush();
125
+ return lines.length ? lines : [''];
126
+ }
127
+
128
+ class TerminalRenderer {
129
+ constructor(io, options) {
130
+ this.stdout = io.stdout;
131
+ this.stdin = io.stdin;
132
+ this.env = io.env || process.env;
133
+ this.forcedNarrow = Boolean(options.narrow);
134
+ this.colorMode = detectColorMode(this.stdout, this.env, options);
135
+ this.noColor = this.colorMode === 'plain';
136
+ this.ascii = typeof this.stdout.getColorDepth === 'function'
137
+ && this.stdout.getColorDepth(this.env) < 4;
138
+ this.static = Boolean(
139
+ options.static ||
140
+ options.json ||
141
+ this.noColor ||
142
+ this.env.CI ||
143
+ prefersReducedMotion(this.env) ||
144
+ !this.stdin.isTTY ||
145
+ !this.stdout.isTTY,
146
+ );
147
+ this.dynamic = Boolean(this.stdout.isTTY && !this.static && !this.noColor);
148
+ this.cursorHidden = false;
149
+ this.paint = null;
150
+ this.onResize = () => {
151
+ if (this.paint) this.paint();
152
+ };
153
+ if (typeof this.stdout.on === 'function') {
154
+ this.stdout.on('resize', this.onResize);
155
+ }
156
+ }
157
+
158
+ get width() {
159
+ return this.forcedNarrow ? Math.min(this.stdout.columns || 80, 60) : (this.stdout.columns || 80);
160
+ }
161
+
162
+ get narrow() {
163
+ return this.forcedNarrow || this.width < 76;
164
+ }
165
+
166
+ get block() {
167
+ return this.ascii ? '#' : '█';
168
+ }
169
+
170
+ get brand() {
171
+ return this.ascii ? 'SIGMA SKILLS' : 'Σ SIGMA SKILLS';
172
+ }
173
+
174
+ style(value, hex, bold = false) {
175
+ if (this.noColor) return value;
176
+ if (this.colorMode === 'truecolor') {
177
+ return `${truecolorFg(hex, bold)}${value}${RESET}`;
178
+ }
179
+ if (this.colorMode === 'ansi256') {
180
+ const [red, green, blue] = hexToRgb(hex);
181
+ return `\x1b[${bold ? '1;' : ''}38;5;${rgbTo256(red, green, blue)}m${value}${RESET}`;
182
+ }
183
+ const code = ANSI16_FG[hex] || 37;
184
+ return `\x1b[${bold ? '1;' : ''}${code}m${value}${RESET}`;
185
+ }
186
+
187
+ fill() {
188
+ if (this.colorMode === 'truecolor') return truecolorBg(EMBERFORGE_PALETTE.base);
189
+ if (this.colorMode === 'ansi256') {
190
+ const [red, green, blue] = hexToRgb(EMBERFORGE_PALETTE.base);
191
+ return `\x1b[48;5;${rgbTo256(red, green, blue)}m`;
192
+ }
193
+ if (this.colorMode === 'ansi16') return '\x1b[40m';
194
+ return '';
195
+ }
196
+
197
+ start() {
198
+ if (this.dynamic) {
199
+ this.stdout.write(HIDE_CURSOR);
200
+ this.cursorHidden = true;
201
+ }
202
+ }
203
+
204
+ cleanup() {
205
+ this.paint = null;
206
+ if (typeof this.stdout.removeListener === 'function') {
207
+ this.stdout.removeListener('resize', this.onResize);
208
+ }
209
+ if (this.cursorHidden) {
210
+ this.stdout.write(`${RESET}${SHOW_CURSOR}`);
211
+ this.cursorHidden = false;
212
+ } else if (this.dynamic) {
213
+ this.stdout.write(`${RESET}${SHOW_CURSOR}`);
214
+ }
215
+ }
216
+
217
+ screen(lines) {
218
+ const content = lines.join('\n');
219
+ if (this.dynamic) {
220
+ this.stdout.write(`${CLEAR}${this.fill()}${content}${RESET}\n`);
221
+ } else {
222
+ this.stdout.write(`${content}\n`);
223
+ }
224
+ }
225
+
226
+ line(value = '') {
227
+ this.stdout.write(value.endsWith('\n') ? value : `${value}\n`);
228
+ }
229
+ }
230
+
231
+ class KeyInput {
232
+ constructor(stdin) {
233
+ this.stdin = stdin;
234
+ this.queue = [];
235
+ this.waiters = [];
236
+ this.ended = false;
237
+ this.closed = false;
238
+ this.previousRawMode = Boolean(stdin.isRaw);
239
+ this.changedRawMode = false;
240
+
241
+ readline.emitKeypressEvents(stdin);
242
+ this.onKeypress = (_value, key = {}) => {
243
+ const normalized = key.ctrl && key.name === 'c'
244
+ ? { name: 'ctrl-c', ch: '', sequence: '', shift: false }
245
+ : {
246
+ name: key.name || key.sequence || 'unknown',
247
+ ch: typeof _value === 'string' ? _value : '',
248
+ sequence: key.sequence || '',
249
+ shift: Boolean(key.shift),
250
+ };
251
+ this.push(normalized);
252
+ };
253
+ this.onEnd = () => {
254
+ this.ended = true;
255
+ this.push({ name: 'eof' });
256
+ };
257
+ this.onSigint = () => this.push({ name: 'ctrl-c' });
258
+
259
+ stdin.on('keypress', this.onKeypress);
260
+ stdin.once('end', this.onEnd);
261
+ process.on('SIGINT', this.onSigint);
262
+
263
+ if (stdin.isTTY && typeof stdin.setRawMode === 'function') {
264
+ stdin.setRawMode(true);
265
+ this.changedRawMode = true;
266
+ }
267
+ stdin.resume?.();
268
+ }
269
+
270
+ push(key) {
271
+ const waiter = this.waiters.shift();
272
+ if (waiter) {
273
+ clearTimeout(waiter.timer);
274
+ waiter.resolve(key);
275
+ } else {
276
+ this.queue.push(key);
277
+ }
278
+ }
279
+
280
+ next(timeoutMs) {
281
+ if (this.queue.length > 0) return Promise.resolve(this.queue.shift());
282
+ if (this.ended) return Promise.resolve({ name: 'eof' });
283
+
284
+ return new Promise((resolve) => {
285
+ const waiter = { resolve, timer: null };
286
+ if (timeoutMs !== undefined) {
287
+ waiter.timer = setTimeout(() => {
288
+ const index = this.waiters.indexOf(waiter);
289
+ if (index !== -1) this.waiters.splice(index, 1);
290
+ resolve(null);
291
+ }, timeoutMs);
292
+ }
293
+ this.waiters.push(waiter);
294
+ });
295
+ }
296
+
297
+ close() {
298
+ if (this.closed) return;
299
+ this.closed = true;
300
+ this.stdin.removeListener('keypress', this.onKeypress);
301
+ this.stdin.removeListener('end', this.onEnd);
302
+ process.removeListener('SIGINT', this.onSigint);
303
+ for (const waiter of this.waiters.splice(0)) {
304
+ clearTimeout(waiter.timer);
305
+ waiter.resolve({ name: 'eof' });
306
+ }
307
+ if (this.changedRawMode) {
308
+ try {
309
+ this.stdin.setRawMode(this.previousRawMode);
310
+ } catch {
311
+ // The stream may already be closed. Cursor restoration still runs.
312
+ }
313
+ }
314
+ }
315
+ }
316
+
317
+ function isHelpKey(key) {
318
+ return Boolean(key && (key.ch === '?' || key.sequence === '?' || key.name === '?'));
319
+ }
320
+
321
+ function helpLines(renderer) {
322
+ return [
323
+ renderer.style(renderer.brand, EMBERFORGE_PALETTE.gold, true),
324
+ 'Keyboard',
325
+ '',
326
+ '↑/↓ move focus',
327
+ 'space toggle selection',
328
+ 'type to search Agent Hosts',
329
+ 'enter continue',
330
+ 'esc cancel',
331
+ 'Ctrl+C abort',
332
+ 'any key skips the opening reveal',
333
+ '? close help',
334
+ ];
335
+ }
336
+
337
+ async function readKeyedScreen(renderer, input, paint) {
338
+ renderer.paint = paint;
339
+ paint();
340
+ const key = await input.next();
341
+ if (isHelpKey(key)) {
342
+ await showHelp(renderer, input);
343
+ return { help: true, key };
344
+ }
345
+ return { help: false, key };
346
+ }
347
+
348
+ async function showHelp(renderer, input) {
349
+ const paint = () => renderer.screen(helpLines(renderer));
350
+ renderer.paint = paint;
351
+ paint();
352
+ await input.next();
353
+ }
354
+
355
+ function withHelp(hints) {
356
+ return `${hints} · ? help`;
357
+ }
358
+
359
+ function revealLines(renderer, progress = 1) {
360
+ const templates = renderer.narrow
361
+ ? ['██████████', ' ███', ' ███', ' ███', '██████████']
362
+ : ['██████████████████', ' ████', ' ████', ' ████', ' ████', '██████████████████'];
363
+ const sigma = templates.map((row) => row.replaceAll('█', renderer.block));
364
+ const visibleRows = Math.max(1, Math.ceil(sigma.length * progress));
365
+ const firstVisible = sigma.length - visibleRows;
366
+ const fire = [
367
+ EMBERFORGE_PALETTE.red,
368
+ EMBERFORGE_PALETTE.orange,
369
+ EMBERFORGE_PALETTE.gold,
370
+ EMBERFORGE_PALETTE.text,
371
+ ];
372
+ const lines = [''];
373
+
374
+ for (let index = 0; index < sigma.length; index++) {
375
+ if (index < firstVisible) {
376
+ lines.push('');
377
+ continue;
378
+ }
379
+ const colorIndex = Math.min(fire.length - 1, sigma.length - 1 - index);
380
+ lines.push(` ${renderer.style(sigma[index], fire[colorIndex], true)}`);
381
+ }
382
+ lines.push('');
383
+ lines.push(` ${renderer.style('SIGMA SKILLS', EMBERFORGE_PALETTE.gold, true)}`);
384
+ lines.push(` ${renderer.style('Project Installation', EMBERFORGE_PALETTE.orange)}`);
385
+ return lines;
386
+ }
387
+
388
+ async function runReveal(renderer, input) {
389
+ if (renderer.static) {
390
+ renderer.screen(revealLines(renderer));
391
+ return null;
392
+ }
393
+
394
+ const startedAt = Date.now();
395
+ while (true) {
396
+ const elapsed = Date.now() - startedAt;
397
+ const progress = Math.min(1, elapsed / EMBERFORGE_REVEAL_MS);
398
+ renderer.paint = () => renderer.screen(revealLines(renderer, progress));
399
+ renderer.screen(revealLines(renderer, progress));
400
+ if (progress >= 1) return null;
401
+
402
+ const remaining = EMBERFORGE_REVEAL_MS - elapsed;
403
+ const key = await input.next(Math.min(90, remaining));
404
+ if (key) {
405
+ renderer.screen(revealLines(renderer));
406
+ return key.name;
407
+ }
408
+ }
409
+ }
410
+
411
+ function pickerLines(renderer, catalog, selected, cursor, error, scope) {
412
+ const scopeLabel = scope === 'global' ? 'Global Installation' : 'Project Installation (default)';
413
+ const lines = [
414
+ renderer.style(renderer.brand, EMBERFORGE_PALETTE.gold, true),
415
+ `${scopeLabel}${renderer.narrow ? ' · narrow' : ''}`,
416
+ '',
417
+ 'Select skills from this Skill Pack:',
418
+ ];
419
+
420
+ catalog.skills.forEach((skill, index) => {
421
+ const current = index === cursor ? '>' : ' ';
422
+ const checked = selected.has(skill.id) ? 'x' : ' ';
423
+ lines.push(`${current} [${checked}] ${skill.title} (${skill.id})`);
424
+ const descriptionWidth = Math.max(20, renderer.width - 6);
425
+ for (const descriptionLine of wrapWords(skill.description, descriptionWidth)) {
426
+ lines.push(` ${descriptionLine}`);
427
+ }
428
+ });
429
+
430
+ lines.push('');
431
+ lines.push(`Selected: ${selected.size}/${catalog.skills.length}`);
432
+ if (error) lines.push(renderer.style(`Error: ${error}`, EMBERFORGE_PALETTE.red, true));
433
+ lines.push(withHelp(`↑/↓ move · space toggle · a select all${scope === 'global' ? '' : ' · g Global Installation'} · enter continue · esc cancel`));
434
+ return lines;
435
+ }
436
+
437
+ function globalWarningLines(renderer) {
438
+ return [
439
+ renderer.style('Global Installation warning', EMBERFORGE_PALETTE.red, true),
440
+ '',
441
+ 'Global Installation writes skills for this operating-system user.',
442
+ 'Other projects can pick them up. Project Installation remains the safer default.',
443
+ '',
444
+ 'Continue with Global Installation? [y/N]',
445
+ 'y continue · n/enter/esc cancel · ? help',
446
+ ];
447
+ }
448
+
449
+ async function confirmGlobalWarning(renderer, input) {
450
+ while (true) {
451
+ const { help, key } = await readKeyedScreen(renderer, input, () => renderer.screen(globalWarningLines(renderer)));
452
+ if (help) continue;
453
+ if (key.name === 'y') return { confirmed: true, exitCode: 0 };
454
+ if (key.name === 'ctrl-c') return { confirmed: false, exitCode: 130 };
455
+ if (key.name === 'eof' || key.name === 'escape' || key.name === 'n' || key.name === 'return') {
456
+ return { confirmed: false, exitCode: 0 };
457
+ }
458
+ }
459
+ }
460
+
461
+ async function selectSkills(renderer, input, catalog, initialScope = 'project') {
462
+ const selected = new Set();
463
+ let cursor = 0;
464
+ let error = '';
465
+ let scope = initialScope === 'global' ? 'global' : 'project';
466
+
467
+ while (true) {
468
+ const { help, key } = await readKeyedScreen(
469
+ renderer,
470
+ input,
471
+ () => renderer.screen(pickerLines(renderer, catalog, selected, cursor, error, scope)),
472
+ );
473
+ if (help) continue;
474
+ if (key.name === 'ctrl-c') return { cancelled: true, exitCode: 130 };
475
+ if (key.name === 'escape' || key.name === 'eof') return { cancelled: true, exitCode: 0 };
476
+ if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
477
+ cursor = (cursor + catalog.skills.length - 1) % catalog.skills.length;
478
+ }
479
+ if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
480
+ cursor = (cursor + 1) % catalog.skills.length;
481
+ }
482
+ if (key.name === 'space') {
483
+ const skillId = catalog.skills[cursor].id;
484
+ if (selected.has(skillId)) selected.delete(skillId);
485
+ else selected.add(skillId);
486
+ error = '';
487
+ }
488
+ if (key.name === 'a') {
489
+ if (selected.size === catalog.skills.length) selected.clear();
490
+ else catalog.skills.forEach((skill) => selected.add(skill.id));
491
+ error = '';
492
+ }
493
+ if (key.name === 'g' && scope !== 'global') {
494
+ const warning = await confirmGlobalWarning(renderer, input);
495
+ if (!warning.confirmed) return { cancelled: true, exitCode: warning.exitCode };
496
+ scope = 'global';
497
+ error = '';
498
+ continue;
499
+ }
500
+ if (key.name === 'return') {
501
+ if (selected.size === 0) {
502
+ error = 'Select at least one skill.';
503
+ } else {
504
+ return {
505
+ cancelled: false,
506
+ scope,
507
+ skillIds: catalog.skills.filter((skill) => selected.has(skill.id)).map((skill) => skill.id),
508
+ };
509
+ }
510
+ }
511
+ }
512
+ }
513
+
514
+ function affectedHostLabel(host) {
515
+ return host.detected ? `${host.displayName} [detected]` : host.displayName;
516
+ }
517
+
518
+ function destinationPickerLines(renderer, items, selectedRoots, cursor, query, error, scope) {
519
+ const scopeLabel = scope === 'global' ? 'Global Installation' : 'Project Installation';
520
+ const lines = [
521
+ renderer.style(renderer.brand, EMBERFORGE_PALETTE.gold, true),
522
+ `${scopeLabel} · destinations${renderer.narrow ? ' · narrow' : ''}`,
523
+ '',
524
+ 'Only .agents/skills is selected by default. Host-specific destinations stay unselected.',
525
+ 'Detection labels Agent Hosts; it never selects host-specific destinations.',
526
+ ];
527
+
528
+ if (query) {
529
+ lines.push('');
530
+ lines.push(`Search: ${query}`);
531
+ } else {
532
+ lines.push('');
533
+ }
534
+
535
+ items.forEach((item, index) => {
536
+ const current = index === cursor ? '>' : ' ';
537
+ const checked = selectedRoots.has(item.relativeRoot) ? 'x' : ' ';
538
+ const title = item.kind === 'host'
539
+ ? `${item.displayName} (${item.id})`
540
+ : `${item.relativeRoot}${item.universal ? ' (universal default)' : ''}`;
541
+ const marker = `${current} [${checked}] `;
542
+ const titleLines = wrapWords(title, Math.max(20, renderer.width - marker.length));
543
+ titleLines.forEach((titleLine, titleIndex) => {
544
+ lines.push(titleIndex === 0 ? `${marker}${titleLine}` : ` ${titleLine}`);
545
+ });
546
+ if (item.kind === 'group' && item.universal) {
547
+ const names = item.hosts.map((host) => affectedHostLabel(host)).join(', ');
548
+ for (const wrapped of wrapWords(`Affected Agent Hosts: ${names}`, Math.max(20, renderer.width - 6))) {
549
+ lines.push(` ${wrapped}`);
550
+ }
551
+ } else if (item.kind === 'host') {
552
+ const detected = item.detected ? ' [detected]' : '';
553
+ for (const wrapped of wrapWords(`${item.relativeRoot}${detected}`, Math.max(20, renderer.width - 6))) {
554
+ lines.push(` ${wrapped}`);
555
+ }
556
+ } else if (item.hosts?.length) {
557
+ for (const wrapped of wrapWords(item.hosts.map((host) => affectedHostLabel(host)).join(', '), Math.max(20, renderer.width - 6))) {
558
+ lines.push(` ${wrapped}`);
559
+ }
560
+ }
561
+ if (item.absoluteRoot) {
562
+ for (const wrapped of wrapWords(item.absoluteRoot, Math.max(20, renderer.width - 6))) {
563
+ lines.push(` ${wrapped}`);
564
+ }
565
+ }
566
+ });
567
+
568
+ lines.push('');
569
+ lines.push(`Selected destinations: ${selectedRoots.size}`);
570
+ if (error) lines.push(renderer.style(`Error: ${error}`, EMBERFORGE_PALETTE.red, true));
571
+ lines.push(withHelp('Type to search every Agent Host · space toggle · enter continue · esc cancel'));
572
+ return lines;
573
+ }
574
+
575
+ function selectableGroups(groups) {
576
+ return groups.filter((group) => group.selectable);
577
+ }
578
+
579
+ function pickerItems(groups, query) {
580
+ if (query) {
581
+ return searchHosts(groups, query)
582
+ .filter((host) => host.relativeRoot)
583
+ .map((host) => ({
584
+ kind: 'host',
585
+ id: host.id,
586
+ displayName: host.displayName,
587
+ relativeRoot: host.relativeRoot,
588
+ absoluteRoot: host.group?.absoluteRoot || '',
589
+ detected: host.detected,
590
+ universal: host.relativeRoot === UNIVERSAL_PROJECT_DESTINATION,
591
+ }));
592
+ }
593
+ return selectableGroups(groups).map((group) => ({
594
+ kind: 'group',
595
+ relativeRoot: group.relativeRoot,
596
+ absoluteRoot: group.absoluteRoot,
597
+ universal: group.universal,
598
+ hosts: group.hosts,
599
+ }));
600
+ }
601
+
602
+ function isSearchChar(key) {
603
+ if (!key) return false;
604
+ if (key.name === 'space' || key.name === 'return' || key.name === 'escape') return false;
605
+ const ch = key.ch || '';
606
+ if (ch.length === 1 && /[A-Za-z0-9._/-]/.test(ch)) return true;
607
+ return Boolean(key.name && key.name.length === 1 && /[A-Za-z0-9._/-]/.test(key.name));
608
+ }
609
+
610
+ async function selectDestinations(renderer, input, groups, scope = 'project') {
611
+ const selectedRoots = new Set(defaultSelectedRoots(groups));
612
+ let query = '';
613
+ let cursor = 0;
614
+ let error = '';
615
+
616
+ while (true) {
617
+ const items = pickerItems(groups, query);
618
+ if (items.length === 0) cursor = 0;
619
+ else cursor = ((cursor % items.length) + items.length) % items.length;
620
+ const { help, key } = await readKeyedScreen(
621
+ renderer,
622
+ input,
623
+ () => renderer.screen(destinationPickerLines(renderer, pickerItems(groups, query), selectedRoots, cursor, query, error, scope)),
624
+ );
625
+ if (help) continue;
626
+ if (key.name === 'ctrl-c') return { cancelled: true, exitCode: 130 };
627
+ if (key.name === 'escape') {
628
+ if (query) {
629
+ query = '';
630
+ cursor = 0;
631
+ error = '';
632
+ continue;
633
+ }
634
+ return { cancelled: true, exitCode: 0 };
635
+ }
636
+ if (key.name === 'eof') return { cancelled: true, exitCode: 0 };
637
+ if (key.name === 'up' || (key.name === 'tab' && key.shift)) {
638
+ if (items.length) cursor = (cursor + items.length - 1) % items.length;
639
+ }
640
+ if (key.name === 'down' || (key.name === 'tab' && !key.shift)) {
641
+ if (items.length) cursor = (cursor + 1) % items.length;
642
+ }
643
+ if (key.name === 'backspace') {
644
+ query = query.slice(0, -1);
645
+ cursor = 0;
646
+ error = '';
647
+ }
648
+ if (isSearchChar(key)) {
649
+ query += key.ch || key.name;
650
+ cursor = 0;
651
+ error = '';
652
+ }
653
+ if (key.name === 'space') {
654
+ const item = items[cursor];
655
+ if (item?.relativeRoot) {
656
+ if (selectedRoots.has(item.relativeRoot)) selectedRoots.delete(item.relativeRoot);
657
+ else selectedRoots.add(item.relativeRoot);
658
+ error = '';
659
+ }
660
+ }
661
+ if (key.name === 'return') {
662
+ if (selectedRoots.size === 0) {
663
+ error = 'Select at least one destination.';
664
+ } else {
665
+ return {
666
+ cancelled: false,
667
+ selectedRoots: selectableGroups(groups)
668
+ .map((group) => group.relativeRoot)
669
+ .filter((root) => selectedRoots.has(root)),
670
+ };
671
+ }
672
+ }
673
+ }
674
+ }
675
+
676
+ function summaryLines(renderer, plans, scope = 'project') {
677
+ const title = scope === 'global' ? 'Confirm Global Installation' : 'Confirm Project Installation';
678
+ const lines = [
679
+ renderer.style(title, EMBERFORGE_PALETTE.gold, true),
680
+ '',
681
+ 'Resolved destinations:',
682
+ ];
683
+ for (const plan of plans) {
684
+ lines.push(`${plan.title} (${plan.skill})`);
685
+ for (const dest of plan.destinations || [{ destination: plan.destination }]) {
686
+ const method = dest.method ? ` [${dest.method}]` : '';
687
+ for (const wrapped of wrapWords(`${dest.destination}${method}`, Math.max(20, renderer.width - 2))) {
688
+ lines.push(` ${wrapped}`);
689
+ }
690
+ if (dest.dependsOn) {
691
+ for (const wrapped of wrapWords(`depends on ${dest.dependsOn}`, Math.max(20, renderer.width - 4))) {
692
+ lines.push(` ${wrapped}`);
693
+ }
694
+ }
695
+ if (scope === 'global') {
696
+ const hostNames = (dest.hosts || []).map((host) => host.displayName).join(', ');
697
+ if (hostNames) lines.push(` Agent Hosts: ${hostNames}`);
698
+ if (dest.method) lines.push(` Method: ${dest.method}`);
699
+ if (dest.overwrite) lines.push(` Overwrite: ${dest.overwrite}`);
700
+ if (dest.delete) lines.push(` Delete: ${dest.delete}`);
701
+ if (dest.backup) lines.push(` Backup: ${dest.backup}`);
702
+ }
703
+ }
704
+ }
705
+ lines.push('');
706
+ lines.push(`Install ${plans.length} selected skill${plans.length === 1 ? '' : 's'}? [y/N]`);
707
+ lines.push(withHelp('y confirm · n/enter/esc cancel'));
708
+ return lines;
709
+ }
710
+
711
+ function methodPickerLines(renderer, cursor) {
712
+ const linkDetail = recommendedLinkMethod() === 'junction'
713
+ ? 'Windows directory junctions to the canonical copy'
714
+ : 'macOS/Linux symbolic links to the canonical copy';
715
+ const options = [
716
+ { title: 'Link (recommended)', detail: linkDetail },
717
+ { title: 'Copy', detail: 'Independent managed copy at every destination' },
718
+ ];
719
+ const lines = [
720
+ renderer.style(renderer.brand, EMBERFORGE_PALETTE.gold, true),
721
+ `Project Installation · method${renderer.narrow ? ' · narrow' : ''}`,
722
+ '',
723
+ 'Choose how host-specific destinations are written. The installer never changes method silently.',
724
+ ];
725
+ options.forEach((option, index) => {
726
+ const current = index === cursor ? '>' : ' ';
727
+ const checked = index === cursor ? 'x' : ' ';
728
+ lines.push(`${current} [${checked}] ${option.title}`);
729
+ for (const wrapped of wrapWords(option.detail, Math.max(20, renderer.width - 6))) {
730
+ lines.push(` ${wrapped}`);
731
+ }
732
+ });
733
+ lines.push('');
734
+ lines.push(withHelp('↑/↓ move · enter continue · esc cancel'));
735
+ return lines;
736
+ }
737
+
738
+ async function selectMethod(renderer, input) {
739
+ let cursor = 0;
740
+ while (true) {
741
+ const { help, key } = await readKeyedScreen(renderer, input, () => renderer.screen(methodPickerLines(renderer, cursor)));
742
+ if (help) continue;
743
+ if (key.name === 'ctrl-c') return { cancelled: true, exitCode: 130 };
744
+ if (key.name === 'escape' || key.name === 'eof') return { cancelled: true, exitCode: 0 };
745
+ if (key.name === 'up' || key.name === 'down' || key.name === 'tab') cursor = cursor === 0 ? 1 : 0;
746
+ if (key.name === 'return' || key.name === 'space') {
747
+ return { cancelled: false, method: cursor === 0 ? 'link' : 'copy' };
748
+ }
749
+ }
750
+ }
751
+
752
+ function fallbackLines(renderer, failure) {
753
+ return [
754
+ renderer.style('Link failed', EMBERFORGE_PALETTE.red, true),
755
+ '',
756
+ ...wrapWords(failure.relativeDestination || failure.destination || '', Math.max(20, renderer.width - 2)),
757
+ `Cause: ${failure.cause}`,
758
+ '',
759
+ 'The installer will not change method silently.',
760
+ 'Install a complete managed copy at this destination instead? [y/N]',
761
+ 'y copy · n/enter/esc leave unchanged · ? help',
762
+ ];
763
+ }
764
+
765
+ async function offerCopyFallback(renderer, input, failure) {
766
+ while (true) {
767
+ const { help, key } = await readKeyedScreen(renderer, input, () => renderer.screen(fallbackLines(renderer, failure)));
768
+ if (help) continue;
769
+ if (key.name === 'y') return 'copy';
770
+ if (key.name === 'ctrl-c') return 'ctrl-c';
771
+ if (key.name === 'n' || key.name === 'escape' || key.name === 'return' || key.name === 'eof') {
772
+ return 'abort';
773
+ }
774
+ }
775
+ }
776
+
777
+ async function confirmPlans(renderer, input, plans, scope = 'project') {
778
+ while (true) {
779
+ const { help, key } = await readKeyedScreen(renderer, input, () => renderer.screen(summaryLines(renderer, plans, scope)));
780
+ if (help) continue;
781
+ if (key.name === 'ctrl-c') return { confirmed: false, exitCode: 130 };
782
+ if (key.name === 'eof' || key.name === 'escape' || key.name === 'n' || key.name === 'return') {
783
+ return { confirmed: false, exitCode: 0 };
784
+ }
785
+ if (key.name === 'y') return { confirmed: true, exitCode: 0 };
786
+ }
787
+ }
788
+
789
+ /**
790
+ * Run the interactive Emberforge Project Installation.
791
+ *
792
+ * @param {object} params
793
+ * @param {object} params.catalog
794
+ * @param {string} params.packageRoot
795
+ * @param {string} params.projectRoot
796
+ * @param {string} [params.customStateDir]
797
+ * @param {object} params.io
798
+ * @param {object} [params.options]
799
+ * @returns {Promise<number>}
800
+ */
801
+ export async function runProjectInstaller(params) {
802
+ const {
803
+ catalog,
804
+ packageRoot,
805
+ projectRoot,
806
+ customStateDir,
807
+ io,
808
+ options = {},
809
+ } = params;
810
+ const env = io.env || process.env;
811
+ const homeDir = path.resolve(params.homeDir || resolveHomeDir(env));
812
+ const renderer = new TerminalRenderer(io, options);
813
+ const input = new KeyInput(io.stdin);
814
+
815
+ renderer.start();
816
+ try {
817
+ const revealKey = await runReveal(renderer, input);
818
+ if (revealKey === 'ctrl-c' || revealKey === 'eof') {
819
+ renderer.line('Installation cancelled. No files were written.');
820
+ return revealKey === 'ctrl-c' ? 130 : 0;
821
+ }
822
+
823
+ if (params.initialScope === 'global') {
824
+ const warning = await confirmGlobalWarning(renderer, input);
825
+ if (!warning.confirmed) {
826
+ renderer.line('Installation cancelled. No files were written.');
827
+ return warning.exitCode;
828
+ }
829
+ }
830
+
831
+ const selection = await selectSkills(renderer, input, catalog, params.initialScope === 'global' ? 'global' : 'project');
832
+ if (selection.cancelled) {
833
+ renderer.line('Installation cancelled. No files were written.');
834
+ return selection.exitCode;
835
+ }
836
+
837
+ const scope = selection.scope || 'project';
838
+ const root = scope === 'global' ? homeDir : projectRoot;
839
+ const destinationGroups = scope === 'global'
840
+ ? listGlobalDestinationGroups({
841
+ registry: params.registry || loadHostRegistry(packageRoot),
842
+ homeDir,
843
+ env,
844
+ })
845
+ : listProjectDestinationGroups({
846
+ registry: params.registry || loadHostRegistry(packageRoot),
847
+ projectRoot,
848
+ env,
849
+ });
850
+ const destinations = await selectDestinations(renderer, input, destinationGroups, scope);
851
+ if (destinations.cancelled) {
852
+ renderer.line('Installation cancelled. No files were written.');
853
+ return destinations.exitCode;
854
+ }
855
+
856
+ const hostSelected = destinations.selectedRoots.some((selectedRoot) => selectedRoot !== UNIVERSAL_PROJECT_DESTINATION);
857
+ let method = hostSelected ? 'link' : 'copy';
858
+ if (hostSelected) {
859
+ const methodChoice = await selectMethod(renderer, input);
860
+ if (methodChoice.cancelled) {
861
+ renderer.line('Installation cancelled. No files were written.');
862
+ return methodChoice.exitCode;
863
+ }
864
+ method = methodChoice.method;
865
+ }
866
+
867
+ const classify = createProjectSkillClassifier({
868
+ catalog,
869
+ projectRoot: root,
870
+ customStateDir,
871
+ scope,
872
+ homeDir,
873
+ });
874
+ const conflictErrors = findDestinationConflicts({
875
+ projectRoot: root,
876
+ skillIds: selection.skillIds,
877
+ selectedRoots: destinations.selectedRoots,
878
+ isOwned: (skillId, destination) => isDestinationOwned(root, skillId, destination, customStateDir, { scope }),
879
+ classify,
880
+ resolvePath: scope === 'global' ? resolveGlobalSkillPath : undefined,
881
+ });
882
+ if (conflictErrors.length > 0) {
883
+ throw new Error(conflictErrors[0]);
884
+ }
885
+
886
+ const plans = selection.skillIds.map((skillId) => createInstallPlan(catalog, {
887
+ skillId,
888
+ projectRoot,
889
+ homeDir,
890
+ scope,
891
+ customStateDir,
892
+ packageRoot,
893
+ selectedRoots: destinations.selectedRoots,
894
+ destinationGroups,
895
+ env,
896
+ method,
897
+ }));
898
+ const conflict = plans.find((plan) => plan.unownedConflict);
899
+ if (conflict) throw createUnownedConflictError(conflict);
900
+ const pending = plans.find((plan) => plan.requiresApproval);
901
+ if (pending) {
902
+ for (const plan of plans) {
903
+ renderer.line(formatPlanHuman(plan));
904
+ }
905
+ throw createNeedsResolutionError(pending);
906
+ }
907
+
908
+ const confirmation = await confirmPlans(renderer, input, plans, scope);
909
+ if (!confirmation.confirmed) {
910
+ renderer.line('Installation cancelled. No files were written.');
911
+ return confirmation.exitCode;
912
+ }
913
+
914
+ renderer.cleanup();
915
+
916
+ const copyRoots = [];
917
+ for (const skillId of selection.skillIds) {
918
+ let result;
919
+ while (true) {
920
+ try {
921
+ result = executeProjectInstall({
922
+ catalog,
923
+ skillId,
924
+ projectRoot,
925
+ homeDir,
926
+ scope,
927
+ customStateDir,
928
+ packageRoot,
929
+ selectedRoots: destinations.selectedRoots,
930
+ destinationGroups,
931
+ env,
932
+ method,
933
+ copyRoots,
934
+ createLink: params.createLink,
935
+ });
936
+ break;
937
+ } catch (err) {
938
+ if (!err.linkFailure) throw err;
939
+ const decision = await offerCopyFallback(renderer, input, err.linkFailure);
940
+ if (decision !== 'copy') {
941
+ renderer.line('Installation cancelled. No files were written.');
942
+ return decision === 'ctrl-c' ? 130 : 0;
943
+ }
944
+ copyRoots.push(err.linkFailure.relativeRoot);
945
+ }
946
+ }
947
+ for (const dest of result.plan.destinations) {
948
+ const methodLabel = dest.method ? ` [${dest.method}]` : '';
949
+ renderer.line(`Installed ${result.plan.title} (${result.plan.skill}) to ${dest.destination}${methodLabel}`);
950
+ }
951
+ }
952
+ const doneLabel = scope === 'global' ? 'Global Installation' : 'Project Installation';
953
+ renderer.line(`${doneLabel} complete: ${selection.skillIds.length} skill${selection.skillIds.length === 1 ? '' : 's'} installed.`);
954
+ return 0;
955
+ } finally {
956
+ input.close();
957
+ renderer.cleanup();
958
+ }
959
+ }