@compilr-dev/cli 0.6.6 → 0.7.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 (31) hide show
  1. package/dist/commands-v2/handlers/core.js +10 -1
  2. package/dist/commands-v2/handlers/index.d.ts +1 -0
  3. package/dist/commands-v2/handlers/index.js +3 -0
  4. package/dist/commands-v2/handlers/settings.js +21 -5
  5. package/dist/commands-v2/handlers/skill.d.ts +10 -0
  6. package/dist/commands-v2/handlers/skill.js +63 -0
  7. package/dist/commands-v2/index.d.ts +1 -1
  8. package/dist/commands-v2/index.js +1 -1
  9. package/dist/commands-v2/registry.d.ts +4 -0
  10. package/dist/commands-v2/registry.js +19 -0
  11. package/dist/compilr-diff-companion.vsix +0 -0
  12. package/dist/index.js +8 -12
  13. package/dist/repl-helpers.d.ts +29 -1
  14. package/dist/repl-helpers.js +77 -7
  15. package/dist/repl-v2.js +29 -3
  16. package/dist/ui/conversation.js +1 -2
  17. package/dist/ui/markdown-renderer.d.ts +43 -0
  18. package/dist/ui/markdown-renderer.js +474 -0
  19. package/dist/ui/overlay/impl/artifact-detail-overlay-v2.js +1 -2
  20. package/dist/ui/overlay/impl/document-detail-overlay-v2.js +1 -2
  21. package/dist/ui/overlay/impl/help-overlay-v2.d.ts +7 -1
  22. package/dist/ui/overlay/impl/help-overlay-v2.js +19 -2
  23. package/dist/ui/overlay/impl/skill-detail-overlay-v2.d.ts +91 -0
  24. package/dist/ui/overlay/impl/skill-detail-overlay-v2.js +863 -0
  25. package/dist/ui/overlay/impl/skill-editor-overlay.d.ts +56 -0
  26. package/dist/ui/overlay/impl/skill-editor-overlay.js +493 -0
  27. package/dist/ui/overlay/impl/skills-overlay-v2.d.ts +83 -0
  28. package/dist/ui/overlay/impl/skills-overlay-v2.js +1095 -0
  29. package/dist/utils/skill-paths.d.ts +21 -0
  30. package/dist/utils/skill-paths.js +44 -0
  31. package/package.json +7 -6
@@ -0,0 +1,1095 @@
1
+ /**
2
+ * Skills Overlay V2
3
+ *
4
+ * Displays custom and built-in skills in a tabbed, searchable list.
5
+ * Uses TabbedListOverlayV2 for consistent overlay UX.
6
+ *
7
+ * Features:
8
+ * - Tabs: Custom (project + user), Built-in (SDK, read-only)
9
+ * - Detail view for custom skills (metadata + body preview + validation)
10
+ * - Enable/disable toggle, delete, rename, validate
11
+ * - New skill creation with name + scope prompts
12
+ * - Edit via alternate screen raw editor
13
+ * - ⚠ validation indicator on skills with issues
14
+ *
15
+ * Spec: project-docs/00-requirements/compilr-dev-cli/skills-overlay-spec.md
16
+ */
17
+ import { promises as fs, readFileSync } from 'node:fs';
18
+ import * as terminal from '../../terminal.js';
19
+ import { TabbedListOverlayV2, BaseScreen, stay, popScreen, closeOverlay, isEscape, isCtrlC, isEnter, isUpArrow, isDownArrow, renderBorder, truncate, isPrintable, getPrintableChar, } from '../../base/index.js';
20
+ import { isReservedSkillName, parseSkillMarkdown, loadSkillsFromDir, platformSkills, builtinSkills, validateSkillQuality, buildForkContent, buildNewSkillContent, } from '@compilr-dev/sdk';
21
+ import { getSkillsDir, getSkillFolder, getSkillFile, ensureSkillsDir, readScopeConfig, writeScopeConfig, getScopeConfigPath, isValidSkillName, } from '../../../utils/skill-paths.js';
22
+ // =============================================================================
23
+ // Constants
24
+ // =============================================================================
25
+ const PAGE_SIZE = 12;
26
+ const NAME_WIDTH = 24;
27
+ const BODY_PREVIEW_LINES = 10;
28
+ const SKILL_TABS = [
29
+ { id: 'custom', label: 'Custom' },
30
+ { id: 'builtin', label: 'Built-in' },
31
+ { id: 'bindings', label: 'Bindings' },
32
+ ];
33
+ // =============================================================================
34
+ // Data Loading
35
+ // =============================================================================
36
+ function validateSkill(skill, folderName) {
37
+ return validateSkillQuality(skill, folderName).map((issue) => issue.message);
38
+ }
39
+ async function loadCustomSkills(projectPath) {
40
+ const items = [];
41
+ // Load all bindings to map skills → bound commands
42
+ const bindingsBySkill = new Map();
43
+ for (const scope of ['project', 'user']) {
44
+ const config = await readScopeConfig(scope);
45
+ if (config.slashCommands) {
46
+ for (const [cmd, skillName] of Object.entries(config.slashCommands)) {
47
+ const existing = bindingsBySkill.get(skillName) ?? [];
48
+ existing.push(`/${cmd}`);
49
+ bindingsBySkill.set(skillName, existing);
50
+ }
51
+ }
52
+ }
53
+ // Project-scoped skills
54
+ if (projectPath) {
55
+ try {
56
+ const projectDir = getSkillsDir('project');
57
+ const skills = await loadSkillsFromDir(projectDir, 'project');
58
+ for (const skill of skills) {
59
+ const filePath = getSkillFile(skill.name, 'project');
60
+ const issues = validateSkill(skill, skill.name);
61
+ items.push({
62
+ name: skill.name,
63
+ description: skill.description,
64
+ scope: 'project',
65
+ enabled: skill.enabled !== false,
66
+ issues,
67
+ skill,
68
+ filePath,
69
+ bodyPreview: skill.prompt ? skill.prompt.split('\n').slice(0, BODY_PREVIEW_LINES).join('\n') : null,
70
+ targets: skill.compilr,
71
+ boundCommands: bindingsBySkill.get(skill.name) ?? [],
72
+ forkedFrom: skill.forkedFrom,
73
+ });
74
+ }
75
+ }
76
+ catch {
77
+ // Project skills dir may not exist yet
78
+ }
79
+ }
80
+ // User-scoped skills
81
+ try {
82
+ const userDir = getSkillsDir('user');
83
+ const skills = await loadSkillsFromDir(userDir, 'user');
84
+ for (const skill of skills) {
85
+ const filePath = getSkillFile(skill.name, 'user');
86
+ const issues = validateSkill(skill, skill.name);
87
+ items.push({
88
+ name: skill.name,
89
+ description: skill.description,
90
+ scope: 'user',
91
+ enabled: skill.enabled !== false,
92
+ issues,
93
+ skill,
94
+ filePath,
95
+ bodyPreview: skill.prompt ? skill.prompt.split('\n').slice(0, BODY_PREVIEW_LINES).join('\n') : null,
96
+ targets: skill.compilr,
97
+ boundCommands: bindingsBySkill.get(skill.name) ?? [],
98
+ forkedFrom: skill.forkedFrom,
99
+ });
100
+ }
101
+ }
102
+ catch {
103
+ // User skills dir may not exist yet
104
+ }
105
+ return items;
106
+ }
107
+ function loadBuiltinSkills() {
108
+ const seen = new Set();
109
+ const items = [];
110
+ const addSkill = (skill) => {
111
+ if (seen.has(skill.name))
112
+ return;
113
+ seen.add(skill.name);
114
+ items.push({
115
+ name: skill.name,
116
+ description: skill.description,
117
+ scope: 'sdk',
118
+ enabled: true,
119
+ issues: [],
120
+ skill: null,
121
+ filePath: null,
122
+ bodyPreview: null,
123
+ boundCommands: [],
124
+ });
125
+ };
126
+ // SDK platform skills first (have updated descriptions)
127
+ for (const skill of platformSkills)
128
+ addSkill(skill);
129
+ // agents library built-in skills (deduplicated)
130
+ for (const skill of builtinSkills)
131
+ addSkill(skill);
132
+ return items;
133
+ }
134
+ async function loadBindingItems() {
135
+ const items = [];
136
+ for (const scope of ['project', 'user']) {
137
+ const config = await readScopeConfig(scope);
138
+ if (!config.slashCommands)
139
+ continue;
140
+ for (const [cmd, skillName] of Object.entries(config.slashCommands)) {
141
+ items.push({
142
+ name: skillName,
143
+ description: `/${cmd} → ${skillName}`,
144
+ scope: 'binding',
145
+ enabled: true,
146
+ issues: [],
147
+ skill: null,
148
+ filePath: null,
149
+ bodyPreview: null,
150
+ boundCommands: [],
151
+ bindingCommand: cmd,
152
+ bindingScope: scope,
153
+ });
154
+ }
155
+ }
156
+ return items;
157
+ }
158
+ // =============================================================================
159
+ // Scaffold template
160
+ // =============================================================================
161
+ function buildScaffold(name, scope) {
162
+ return buildNewSkillContent(name, scope);
163
+ }
164
+ // =============================================================================
165
+ // Frontmatter helpers (reused from skill.ts)
166
+ // =============================================================================
167
+ function matchFrontmatter(content) {
168
+ if (!content.startsWith('---'))
169
+ return null;
170
+ const lines = content.split('\n');
171
+ if (lines[0] !== '---')
172
+ return null;
173
+ for (let i = 1; i < lines.length; i++) {
174
+ if (lines[i] === '---') {
175
+ return {
176
+ frontmatter: lines.slice(1, i).join('\n'),
177
+ body: lines.slice(i + 1).join('\n'),
178
+ };
179
+ }
180
+ }
181
+ return null;
182
+ }
183
+ function setEnabledFlag(content, enabled) {
184
+ const fm = matchFrontmatter(content);
185
+ if (!fm)
186
+ return content;
187
+ const fmLines = fm.frontmatter.split('\n');
188
+ const idx = fmLines.findIndex((l) => /^enabled\s*:/.test(l));
189
+ if (idx >= 0) {
190
+ fmLines[idx] = `enabled: ${String(enabled)}`;
191
+ }
192
+ else {
193
+ fmLines.push(`enabled: ${String(enabled)}`);
194
+ }
195
+ return ['---', ...fmLines, '---', fm.body].join('\n');
196
+ }
197
+ function replaceFrontmatterName(content, newName) {
198
+ const fm = matchFrontmatter(content);
199
+ if (!fm)
200
+ return content;
201
+ const fmLines = fm.frontmatter.split('\n').map((l) => /^name\s*:/.test(l) ? `name: ${newName}` : l);
202
+ return ['---', ...fmLines, '---', fm.body].join('\n');
203
+ }
204
+ async function pathExists(p) {
205
+ try {
206
+ await fs.stat(p);
207
+ return true;
208
+ }
209
+ catch {
210
+ return false;
211
+ }
212
+ }
213
+ class NewSkillScreen extends BaseScreen {
214
+ styles;
215
+ onCreated;
216
+ phase = 'name';
217
+ nameInput = '';
218
+ selectedScope = 'project';
219
+ error = '';
220
+ hasProject;
221
+ constructor(styles, onCreated, hasProject) {
222
+ super();
223
+ this.styles = styles;
224
+ this.onCreated = onCreated;
225
+ this.hasProject = hasProject;
226
+ if (!hasProject)
227
+ this.selectedScope = 'user';
228
+ }
229
+ render() {
230
+ const s = this.styles;
231
+ const cols = terminal.getTerminalWidth();
232
+ const border = renderBorder(cols, s);
233
+ const lines = [];
234
+ lines.push(border);
235
+ lines.push(` ${s.primaryBold('New Skill')}`);
236
+ lines.push('');
237
+ if (this.phase === 'name') {
238
+ lines.push(` ${s.muted('Name (lowercase letters, digits, hyphens):')}`);
239
+ lines.push(` ${s.primary('> ')}${this.nameInput}${s.primary('█')}`);
240
+ if (this.error) {
241
+ lines.push(` ${s.warning(this.error)}`);
242
+ }
243
+ lines.push('');
244
+ lines.push(` ${s.muted('Enter to continue · Esc to cancel')}`);
245
+ }
246
+ else {
247
+ lines.push(` ${s.muted('Name:')} ${s.primary(this.nameInput)}`);
248
+ lines.push('');
249
+ lines.push(` ${s.muted('Scope:')}`);
250
+ if (this.hasProject) {
251
+ const pCursor = this.selectedScope === 'project' ? s.primary('> ') : ' ';
252
+ const pLabel = this.selectedScope === 'project' ? s.primary('project') : s.muted('project');
253
+ lines.push(` ${pCursor}${pLabel} ${s.muted('(shared with team via version control)')}`);
254
+ }
255
+ const uCursor = this.selectedScope === 'user' ? s.primary('> ') : ' ';
256
+ const uLabel = this.selectedScope === 'user' ? s.primary('user') : s.muted('user');
257
+ lines.push(` ${uCursor}${uLabel} ${s.muted('(personal, cross-project)')}`);
258
+ lines.push('');
259
+ lines.push(` ${s.muted('↑↓ Select · Enter to create · Esc to cancel')}`);
260
+ }
261
+ lines.push(border);
262
+ return lines;
263
+ }
264
+ handleKey(data) {
265
+ if (isCtrlC(data))
266
+ return closeOverlay({ dismissed: true });
267
+ if (isEscape(data))
268
+ return popScreen();
269
+ if (this.phase === 'name') {
270
+ if (isEnter(data)) {
271
+ const name = this.nameInput.trim();
272
+ if (!name) {
273
+ this.error = 'Name cannot be empty.';
274
+ return stay(true);
275
+ }
276
+ if (!isValidSkillName(name)) {
277
+ this.error = 'Use lowercase letters, digits, and hyphens only.';
278
+ return stay(true);
279
+ }
280
+ if (isReservedSkillName(name)) {
281
+ this.error = `'${name}' is reserved by SDK. Use a different name.`;
282
+ return stay(true);
283
+ }
284
+ this.error = '';
285
+ this.phase = 'scope';
286
+ return stay(true);
287
+ }
288
+ // Backspace
289
+ if (data[0] === 127 || data[0] === 8) {
290
+ this.nameInput = this.nameInput.slice(0, -1);
291
+ this.error = '';
292
+ return stay(true);
293
+ }
294
+ // Printable char
295
+ if (isPrintable(data)) {
296
+ this.nameInput += getPrintableChar(data) ?? '';
297
+ this.error = '';
298
+ return stay(true);
299
+ }
300
+ return stay(false);
301
+ }
302
+ // Scope selection phase
303
+ if (isEnter(data)) {
304
+ void this.onCreated(this.nameInput.trim(), this.selectedScope);
305
+ return popScreen();
306
+ }
307
+ if (isUpArrow(data) || isDownArrow(data)) {
308
+ if (this.hasProject) {
309
+ this.selectedScope = this.selectedScope === 'project' ? 'user' : 'project';
310
+ }
311
+ return stay(true);
312
+ }
313
+ if (isPrintable(data)) {
314
+ const ch = getPrintableChar(data) ?? '';
315
+ if (ch === 'p' && this.hasProject) {
316
+ this.selectedScope = 'project';
317
+ return stay(true);
318
+ }
319
+ if (ch === 'u') {
320
+ this.selectedScope = 'user';
321
+ return stay(true);
322
+ }
323
+ }
324
+ return stay(false);
325
+ }
326
+ getMinHeight() {
327
+ return 12;
328
+ }
329
+ }
330
+ // =============================================================================
331
+ // Rename Input Screen
332
+ // =============================================================================
333
+ class RenameScreen extends BaseScreen {
334
+ currentName;
335
+ scope;
336
+ styles;
337
+ onRenamed;
338
+ nameInput;
339
+ error = '';
340
+ constructor(currentName, scope, styles, onRenamed) {
341
+ super();
342
+ this.currentName = currentName;
343
+ this.scope = scope;
344
+ this.styles = styles;
345
+ this.onRenamed = onRenamed;
346
+ this.nameInput = currentName;
347
+ }
348
+ render() {
349
+ const s = this.styles;
350
+ const cols = terminal.getTerminalWidth();
351
+ const border = renderBorder(cols, s);
352
+ const lines = [];
353
+ lines.push(border);
354
+ lines.push(` ${s.primaryBold('Rename Skill')}`);
355
+ lines.push('');
356
+ lines.push(` ${s.muted('Current:')} ${this.currentName}`);
357
+ lines.push(` ${s.muted('New name:')}`);
358
+ lines.push(` ${s.primary('> ')}${this.nameInput}${s.primary('█')}`);
359
+ if (this.error) {
360
+ lines.push(` ${s.warning(this.error)}`);
361
+ }
362
+ lines.push('');
363
+ lines.push(` ${s.muted('Enter to rename · Esc to cancel')}`);
364
+ lines.push(border);
365
+ return lines;
366
+ }
367
+ handleKey(data) {
368
+ if (isCtrlC(data))
369
+ return closeOverlay({ dismissed: true });
370
+ if (isEscape(data))
371
+ return popScreen();
372
+ if (isEnter(data)) {
373
+ const name = this.nameInput.trim();
374
+ if (!name || name === this.currentName)
375
+ return popScreen();
376
+ if (!isValidSkillName(name)) {
377
+ this.error = 'Use lowercase letters, digits, and hyphens only.';
378
+ return stay(true);
379
+ }
380
+ if (isReservedSkillName(name)) {
381
+ this.error = `'${name}' is reserved by SDK.`;
382
+ return stay(true);
383
+ }
384
+ void this.onRenamed(this.currentName, name, this.scope);
385
+ return popScreen();
386
+ }
387
+ if (data[0] === 127 || data[0] === 8) {
388
+ this.nameInput = this.nameInput.slice(0, -1);
389
+ this.error = '';
390
+ return stay(true);
391
+ }
392
+ if (isPrintable(data)) {
393
+ this.nameInput += getPrintableChar(data) ?? '';
394
+ this.error = '';
395
+ return stay(true);
396
+ }
397
+ return stay(false);
398
+ }
399
+ getMinHeight() {
400
+ return 10;
401
+ }
402
+ }
403
+ // =============================================================================
404
+ // Delete Confirmation Screen
405
+ // =============================================================================
406
+ class DeleteConfirmScreen extends BaseScreen {
407
+ item;
408
+ styles;
409
+ onConfirmed;
410
+ constructor(item, styles, onConfirmed) {
411
+ super();
412
+ this.item = item;
413
+ this.styles = styles;
414
+ this.onConfirmed = onConfirmed;
415
+ }
416
+ render() {
417
+ const s = this.styles;
418
+ const cols = terminal.getTerminalWidth();
419
+ const border = renderBorder(cols, s);
420
+ return [
421
+ border,
422
+ ` ${s.warning('Delete Skill')}`,
423
+ '',
424
+ ` ${s.muted('Are you sure you want to delete')} ${s.primaryBold(this.item.name)} ${s.muted(`(${this.item.scope} scope)?`)}`,
425
+ ` ${s.muted('This will remove the entire skill folder.')}`,
426
+ '',
427
+ ` ${s.muted('y Confirm · n/Esc Cancel')}`,
428
+ border,
429
+ ];
430
+ }
431
+ handleKey(data) {
432
+ if (isCtrlC(data))
433
+ return closeOverlay({ dismissed: true });
434
+ if (isEscape(data))
435
+ return popScreen();
436
+ if (isPrintable(data)) {
437
+ const ch = getPrintableChar(data) ?? '';
438
+ if (ch === 'y') {
439
+ void this.onConfirmed();
440
+ return popScreen();
441
+ }
442
+ if (ch === 'n')
443
+ return popScreen();
444
+ }
445
+ return stay(false);
446
+ }
447
+ getMinHeight() {
448
+ return 8;
449
+ }
450
+ }
451
+ // =============================================================================
452
+ // Unbind Confirmation Screen
453
+ // =============================================================================
454
+ // UnbindConfirmScreen removed — replaced by SkillBindingScreen
455
+ // =============================================================================
456
+ // Fork Screen (for SDK skills)
457
+ // =============================================================================
458
+ class ForkScreen extends BaseScreen {
459
+ sdkSkillName;
460
+ styles;
461
+ onForked;
462
+ nameInput;
463
+ selectedScope = 'project';
464
+ error = '';
465
+ hasProject;
466
+ constructor(sdkSkillName, styles, hasProject, onForked) {
467
+ super();
468
+ this.sdkSkillName = sdkSkillName;
469
+ this.styles = styles;
470
+ this.onForked = onForked;
471
+ this.hasProject = hasProject;
472
+ this.nameInput = sdkSkillName + '-custom';
473
+ if (!hasProject)
474
+ this.selectedScope = 'user';
475
+ }
476
+ render() {
477
+ const s = this.styles;
478
+ const cols = terminal.getTerminalWidth();
479
+ const border = renderBorder(cols, s);
480
+ const lines = [];
481
+ lines.push(border);
482
+ lines.push(` ${s.primaryBold('Fork SDK Skill')}`);
483
+ lines.push(` ${s.muted('Source:')} ${s.secondary(this.sdkSkillName)} ${s.muted('(SDK)')}`);
484
+ lines.push('');
485
+ lines.push(` ${s.muted('New name (must be different from SDK name):')}`);
486
+ lines.push(` ${s.primary('> ')}${this.nameInput}${s.primary('█')}`);
487
+ if (this.error) {
488
+ lines.push(` ${s.warning(this.error)}`);
489
+ }
490
+ lines.push('');
491
+ lines.push(` ${s.muted('Scope:')}`);
492
+ if (this.hasProject) {
493
+ const pCursor = this.selectedScope === 'project' ? s.primary('> ') : ' ';
494
+ const pLabel = this.selectedScope === 'project' ? s.primary('project') : s.muted('project');
495
+ lines.push(` ${pCursor}${pLabel}`);
496
+ }
497
+ const uCursor = this.selectedScope === 'user' ? s.primary('> ') : ' ';
498
+ const uLabel = this.selectedScope === 'user' ? s.primary('user') : s.muted('user');
499
+ lines.push(` ${uCursor}${uLabel}`);
500
+ lines.push('');
501
+ lines.push(` ${s.muted('Tab Switch scope · Enter Fork · Esc Cancel')}`);
502
+ lines.push(border);
503
+ return lines;
504
+ }
505
+ handleKey(data) {
506
+ if (isCtrlC(data))
507
+ return closeOverlay({ dismissed: true });
508
+ if (isEscape(data))
509
+ return popScreen();
510
+ if (isEnter(data)) {
511
+ const name = this.nameInput.trim();
512
+ if (!name) {
513
+ this.error = 'Name cannot be empty.';
514
+ return stay(true);
515
+ }
516
+ if (!isValidSkillName(name)) {
517
+ this.error = 'Use lowercase letters, digits, and hyphens.';
518
+ return stay(true);
519
+ }
520
+ if (isReservedSkillName(name)) {
521
+ this.error = `'${name}' is reserved. Choose a different name.`;
522
+ return stay(true);
523
+ }
524
+ if (name === this.sdkSkillName) {
525
+ this.error = 'Fork name must differ from SDK name.';
526
+ return stay(true);
527
+ }
528
+ void this.onForked(name, this.selectedScope);
529
+ return popScreen();
530
+ }
531
+ // Tab to switch scope
532
+ if (data[0] === 0x09 && this.hasProject) {
533
+ this.selectedScope = this.selectedScope === 'project' ? 'user' : 'project';
534
+ return stay(true);
535
+ }
536
+ if (isUpArrow(data) || isDownArrow(data)) {
537
+ if (this.hasProject) {
538
+ this.selectedScope = this.selectedScope === 'project' ? 'user' : 'project';
539
+ }
540
+ return stay(true);
541
+ }
542
+ if (data[0] === 127 || data[0] === 8) {
543
+ this.nameInput = this.nameInput.slice(0, -1);
544
+ this.error = '';
545
+ return stay(true);
546
+ }
547
+ if (isPrintable(data)) {
548
+ this.nameInput += getPrintableChar(data) ?? '';
549
+ this.error = '';
550
+ return stay(true);
551
+ }
552
+ return stay(false);
553
+ }
554
+ getMinHeight() { return 14; }
555
+ }
556
+ class SkillBindingScreen extends BaseScreen {
557
+ skillName;
558
+ styles;
559
+ onChanged;
560
+ mode = 'list';
561
+ bindings = [];
562
+ selectedIndex = 0;
563
+ addInput = '';
564
+ addScope = 'user';
565
+ error = '';
566
+ hasProject;
567
+ constructor(skillName, styles, onChanged, hasProject) {
568
+ super();
569
+ this.skillName = skillName;
570
+ this.styles = styles;
571
+ this.onChanged = onChanged;
572
+ this.hasProject = hasProject;
573
+ if (hasProject)
574
+ this.addScope = 'project';
575
+ this.loadBindingsSync();
576
+ }
577
+ loadBindingsSync() {
578
+ this.bindings = [];
579
+ for (const scope of ['project', 'user']) {
580
+ try {
581
+ const configPath = getScopeConfigPath(scope);
582
+ const content = readFileSync(configPath, 'utf-8');
583
+ const config = JSON.parse(content);
584
+ if (!config.slashCommands)
585
+ continue;
586
+ for (const [cmd, skill] of Object.entries(config.slashCommands)) {
587
+ if (skill === this.skillName) {
588
+ this.bindings.push({ command: cmd, scope });
589
+ }
590
+ }
591
+ }
592
+ catch { /* config doesn't exist */ }
593
+ }
594
+ if (this.selectedIndex >= this.bindings.length) {
595
+ this.selectedIndex = Math.max(0, this.bindings.length - 1);
596
+ }
597
+ }
598
+ render() {
599
+ const s = this.styles;
600
+ const cols = terminal.getTerminalWidth();
601
+ const border = renderBorder(cols, s);
602
+ const lines = [];
603
+ lines.push(border);
604
+ lines.push(` ${s.primaryBold('Bindings')}${s.muted(' │ ')}${s.secondary(this.skillName)}`);
605
+ lines.push('');
606
+ if (this.mode === 'add') {
607
+ lines.push(` ${s.muted('Slash command name (without /):')}`);
608
+ lines.push(` ${s.muted('/')}${s.primary(this.addInput)}${s.primary('█')}`);
609
+ if (this.error)
610
+ lines.push(` ${s.warning(this.error)}`);
611
+ lines.push('');
612
+ lines.push(` ${s.muted('Scope:')}`);
613
+ if (this.hasProject) {
614
+ const pCur = this.addScope === 'project' ? s.primary('> ') : ' ';
615
+ lines.push(` ${pCur}${this.addScope === 'project' ? s.primary('project') : s.muted('project')}`);
616
+ }
617
+ const uCur = this.addScope === 'user' ? s.primary('> ') : ' ';
618
+ lines.push(` ${uCur}${this.addScope === 'user' ? s.primary('user') : s.muted('user')}`);
619
+ lines.push('');
620
+ lines.push(` ${s.muted('Tab Switch scope · Enter Add · Esc Cancel')}`);
621
+ }
622
+ else if (this.mode === 'confirm-remove' && this.bindings[this.selectedIndex]) {
623
+ const b = this.bindings[this.selectedIndex];
624
+ lines.push(` ${s.warning('Remove binding')} ${s.primary(`/${b.command}`)} ${s.muted(`(${b.scope})?`)}`);
625
+ lines.push('');
626
+ lines.push(` ${s.muted('y Confirm · n/Esc Cancel')}`);
627
+ }
628
+ else {
629
+ // List mode
630
+ if (this.bindings.length === 0) {
631
+ lines.push(` ${s.muted('No bindings for this skill. Press a to add one.')}`);
632
+ }
633
+ else {
634
+ lines.push(` ${s.muted('Slash commands bound to this skill:')}`);
635
+ lines.push('');
636
+ for (let i = 0; i < this.bindings.length; i++) {
637
+ const b = this.bindings[i];
638
+ const cursor = i === this.selectedIndex ? s.primary('> ') : ' ';
639
+ const cmd = i === this.selectedIndex ? s.primary(`/${b.command}`) : s.muted(`/${b.command}`);
640
+ const scopeBadge = b.scope === 'project' ? s.secondary('[project]') : s.muted('[user]');
641
+ lines.push(` ${cursor}${cmd} ${scopeBadge}`);
642
+ }
643
+ }
644
+ lines.push('');
645
+ lines.push(` ${s.muted('a Add · d Remove · Esc Back')}`);
646
+ }
647
+ lines.push(border);
648
+ return lines;
649
+ }
650
+ handleKey(data) {
651
+ if (isCtrlC(data))
652
+ return closeOverlay({ dismissed: true });
653
+ if (this.mode === 'add')
654
+ return this.handleAddKey(data);
655
+ if (this.mode === 'confirm-remove')
656
+ return this.handleConfirmRemoveKey(data);
657
+ return this.handleListKey(data);
658
+ }
659
+ handleListKey(data) {
660
+ if (isEscape(data))
661
+ return popScreen();
662
+ const ch = isPrintable(data) ? (getPrintableChar(data) ?? '') : '';
663
+ if (isUpArrow(data) && this.selectedIndex > 0) {
664
+ this.selectedIndex--;
665
+ return stay(true);
666
+ }
667
+ if (isDownArrow(data) && this.selectedIndex < this.bindings.length - 1) {
668
+ this.selectedIndex++;
669
+ return stay(true);
670
+ }
671
+ if (ch === 'a') {
672
+ this.mode = 'add';
673
+ this.addInput = '';
674
+ this.error = '';
675
+ return stay(true);
676
+ }
677
+ if (ch === 'd' && this.bindings.length > 0) {
678
+ this.mode = 'confirm-remove';
679
+ return stay(true);
680
+ }
681
+ return stay(false);
682
+ }
683
+ handleAddKey(data) {
684
+ if (isEscape(data)) {
685
+ this.mode = 'list';
686
+ return stay(true);
687
+ }
688
+ if (isEnter(data)) {
689
+ const cmd = this.addInput.trim();
690
+ if (!cmd) {
691
+ this.error = 'Name cannot be empty.';
692
+ return stay(true);
693
+ }
694
+ if (!isValidSkillName(cmd)) {
695
+ this.error = 'Use lowercase letters, digits, and hyphens.';
696
+ return stay(true);
697
+ }
698
+ void this.addBinding(cmd, this.addScope);
699
+ return stay(true);
700
+ }
701
+ // Tab to switch scope
702
+ if (data[0] === 0x09 && this.hasProject) {
703
+ this.addScope = this.addScope === 'project' ? 'user' : 'project';
704
+ return stay(true);
705
+ }
706
+ if (isUpArrow(data) || isDownArrow(data)) {
707
+ if (this.hasProject)
708
+ this.addScope = this.addScope === 'project' ? 'user' : 'project';
709
+ return stay(true);
710
+ }
711
+ if (data[0] === 127 || data[0] === 8) {
712
+ this.addInput = this.addInput.slice(0, -1);
713
+ this.error = '';
714
+ return stay(true);
715
+ }
716
+ if (isPrintable(data)) {
717
+ this.addInput += getPrintableChar(data) ?? '';
718
+ this.error = '';
719
+ return stay(true);
720
+ }
721
+ return stay(false);
722
+ }
723
+ handleConfirmRemoveKey(data) {
724
+ if (isEscape(data)) {
725
+ this.mode = 'list';
726
+ return stay(true);
727
+ }
728
+ const ch = isPrintable(data) ? (getPrintableChar(data) ?? '') : '';
729
+ if (ch === 'y') {
730
+ void this.removeBinding();
731
+ return stay(true);
732
+ }
733
+ if (ch === 'n') {
734
+ this.mode = 'list';
735
+ return stay(true);
736
+ }
737
+ return stay(false);
738
+ }
739
+ async addBinding(command, scope) {
740
+ const config = await readScopeConfig(scope);
741
+ if (!config.slashCommands)
742
+ config.slashCommands = {};
743
+ config.slashCommands[command] = this.skillName;
744
+ await writeScopeConfig(scope, config);
745
+ this.loadBindingsSync();
746
+ this.mode = 'list';
747
+ void this.onChanged();
748
+ }
749
+ async removeBinding() {
750
+ if (this.selectedIndex >= this.bindings.length)
751
+ return;
752
+ const b = this.bindings[this.selectedIndex];
753
+ const config = await readScopeConfig(b.scope);
754
+ if (config.slashCommands) {
755
+ const filtered = {};
756
+ for (const [cmd, skill] of Object.entries(config.slashCommands)) {
757
+ if (cmd !== b.command)
758
+ filtered[cmd] = skill;
759
+ }
760
+ config.slashCommands = filtered;
761
+ await writeScopeConfig(b.scope, config);
762
+ }
763
+ this.loadBindingsSync();
764
+ this.mode = 'list';
765
+ void this.onChanged();
766
+ }
767
+ getMinHeight() { return 14; }
768
+ }
769
+ // =============================================================================
770
+ // Skills Overlay V2
771
+ // =============================================================================
772
+ export class SkillsOverlayV2 extends TabbedListOverlayV2 {
773
+ type = 'inline';
774
+ id = 'skills-overlay-v2';
775
+ allItems = [];
776
+ projectPath;
777
+ onOpenDetail;
778
+ constructor(options) {
779
+ const projectPath = options.projectPath;
780
+ // We need the selected item in handleCustomKey but can't reference `this`
781
+ // before super(). Use a deferred reference populated after construction.
782
+ let overlayRef = null;
783
+ // Start with empty items — loadItems will populate async
784
+ super({
785
+ title: 'Skills',
786
+ tabs: SKILL_TABS,
787
+ items: [],
788
+ pageSize: PAGE_SIZE,
789
+ filterByTab: (item, tabId) => {
790
+ if (tabId === 'custom')
791
+ return item.scope !== 'sdk' && item.scope !== 'binding';
792
+ if (tabId === 'builtin')
793
+ return item.scope === 'sdk';
794
+ if (tabId === 'bindings')
795
+ return item.scope === 'binding';
796
+ return true;
797
+ },
798
+ getSearchText: (item) => {
799
+ if (item.bindingCommand)
800
+ return `${item.bindingCommand} ${item.name} ${item.description}`;
801
+ return `${item.name} ${item.description}`;
802
+ },
803
+ renderItem: (item, isSelected, styles) => {
804
+ const cols = terminal.getTerminalWidth();
805
+ const cursor = isSelected ? styles.primary('> ') : ' ';
806
+ // Binding items have their own rendering
807
+ if (item.scope === 'binding' && item.bindingCommand) {
808
+ const cmd = `/${item.bindingCommand}`.padEnd(NAME_WIDTH);
809
+ const cmdStyled = isSelected ? styles.primary(cmd) : styles.muted(cmd);
810
+ const arrow = styles.muted('→');
811
+ const scopeBadge = item.bindingScope === 'project' ? styles.secondary('[project]') : styles.muted('[user]');
812
+ return ` ${cursor}${cmdStyled} ${arrow} ${styles.secondary(item.name)} ${scopeBadge}`;
813
+ }
814
+ const isCustom = item.scope !== 'sdk';
815
+ // Warning indicator
816
+ const warn = isCustom && item.issues.length > 0 ? styles.warning('⚠ ') : ' ';
817
+ // Name
818
+ const nameText = item.name.padEnd(NAME_WIDTH);
819
+ const name = isSelected
820
+ ? styles.primary(nameText)
821
+ : styles.muted(nameText);
822
+ // Disabled indicator
823
+ const disabledTag = !item.enabled && isCustom ? styles.warning(' OFF') : '';
824
+ // Scope badge
825
+ let scopeBadge;
826
+ if (item.scope === 'project')
827
+ scopeBadge = styles.secondary('[project]');
828
+ else if (item.scope === 'user')
829
+ scopeBadge = styles.muted('[user]');
830
+ else
831
+ scopeBadge = styles.muted('[sdk]');
832
+ // Description
833
+ const badgeLen = item.scope.length + 2 + (disabledTag ? 4 : 0);
834
+ const descWidth = Math.max(10, cols - NAME_WIDTH - badgeLen - 14);
835
+ const desc = truncate(item.description.replace(/\s+/g, ' ').trim(), descWidth);
836
+ const descColor = isSelected ? styles.secondary(desc) : styles.muted(desc);
837
+ return `${warn}${cursor}${name} ${scopeBadge}${disabledTag} ${descColor}`;
838
+ },
839
+ handleCustomKey: (key, _tabId) => {
840
+ if (!overlayRef)
841
+ return { handled: false };
842
+ return overlayRef.onCustomKey(key);
843
+ },
844
+ footerHints: (searchMode, activeTabId) => {
845
+ if (searchMode) {
846
+ return 'Type to filter · ↑↓/jk Navigate · Enter · Esc Exit search';
847
+ }
848
+ if (activeTabId === 'builtin') {
849
+ return '←→ Tab · ↑↓/jk · / Search · f Fork · q/Esc Close';
850
+ }
851
+ if (activeTabId === 'bindings') {
852
+ return '←→ Tab · ↑↓/jk · / Search · Enter Manage · d Remove · q/Esc Close';
853
+ }
854
+ return '←→ Tab · ↑↓/jk · / Search · Enter Open · n New · b Bindings · Space On/Off · d Del · q/Esc';
855
+ },
856
+ emptyMessage: 'No custom skills yet. Press n to create one.',
857
+ noResultsMessage: 'No skills match the search.',
858
+ });
859
+ this.projectPath = projectPath;
860
+ this.onOpenDetail = options.onOpenDetail;
861
+ overlayRef = this; // eslint-disable-line @typescript-eslint/no-this-alias
862
+ }
863
+ /**
864
+ * Load skills data. Called after construction.
865
+ */
866
+ async loadItems() {
867
+ const custom = await loadCustomSkills(this.projectPath);
868
+ const builtin = loadBuiltinSkills();
869
+ const bindings = await loadBindingItems();
870
+ // Sort: project first, then user (within custom), all alphabetical
871
+ custom.sort((a, b) => {
872
+ if (a.scope !== b.scope)
873
+ return a.scope === 'project' ? -1 : 1;
874
+ return a.name.localeCompare(b.name);
875
+ });
876
+ builtin.sort((a, b) => a.name.localeCompare(b.name));
877
+ bindings.sort((a, b) => (a.bindingCommand ?? '').localeCompare(b.bindingCommand ?? ''));
878
+ this.allItems = [...custom, ...builtin, ...bindings];
879
+ this.state.items = this.allItems;
880
+ // Re-filter: apply tab filter based on current tab index
881
+ const tabId = SKILL_TABS[this.state.currentTab]?.id ?? 'custom';
882
+ this.state.filteredItems = this.allItems.filter((item) => {
883
+ if (tabId === 'custom')
884
+ return item.scope !== 'sdk' && item.scope !== 'binding';
885
+ if (tabId === 'builtin')
886
+ return item.scope === 'sdk';
887
+ if (tabId === 'bindings')
888
+ return item.scope === 'binding';
889
+ return true;
890
+ });
891
+ this.state.selectedIndex = 0;
892
+ this.state.scrollOffset = 0;
893
+ }
894
+ createDetailScreen(item) {
895
+ // Binding items → open SkillBindingScreen for the bound skill
896
+ if (item.scope === 'binding') {
897
+ return new SkillBindingScreen(item.name, this.getStyles(), async () => {
898
+ await this.loadItems();
899
+ this.requestRender();
900
+ }, !!this.projectPath);
901
+ }
902
+ // Custom skills open in a separate fullscreen overlay (not a pushed screen)
903
+ if (item.scope !== 'sdk' && item.filePath && this.onOpenDetail) {
904
+ void this.onOpenDetail(item.filePath, item.name, item.scope, item.forkedFrom, item.boundCommands).then(async (modified) => {
905
+ if (modified) {
906
+ await this.loadItems();
907
+ }
908
+ this.requestRender();
909
+ });
910
+ }
911
+ // Return null for custom/sdk — we handle custom via overlay, sdk has no detail
912
+ return null;
913
+ }
914
+ /**
915
+ * Handle custom keys on the list view.
916
+ * Called from the config.handleCustomKey closure.
917
+ */
918
+ onCustomKey(data) {
919
+ const selectedItem = this.getSelectedItem() ?? null;
920
+ if (!isPrintable(data) && data.toString() !== ' ')
921
+ return { handled: false };
922
+ const ch = isPrintable(data) ? (getPrintableChar(data) ?? '') : '';
923
+ // n — new skill
924
+ if (ch === 'n') {
925
+ const screen = new NewSkillScreen(this.getStyles(), async (name, scope) => {
926
+ await this.createSkill(name, scope);
927
+ }, !!this.projectPath);
928
+ return { handled: true, pushScreen: screen };
929
+ }
930
+ if (!selectedItem)
931
+ return { handled: false };
932
+ // f — fork (SDK skills only)
933
+ if (ch === 'f' && selectedItem.scope === 'sdk') {
934
+ const screen = new ForkScreen(selectedItem.name, this.getStyles(), !!this.projectPath, async (newName, scope) => {
935
+ await this.forkSkill(selectedItem.name, newName, scope);
936
+ });
937
+ return { handled: true, pushScreen: screen };
938
+ }
939
+ // b — open binding screen (custom skills only)
940
+ if (ch === 'b' && selectedItem.scope !== 'sdk' && selectedItem.scope !== 'binding') {
941
+ const screen = new SkillBindingScreen(selectedItem.name, this.getStyles(), async () => {
942
+ await this.loadItems();
943
+ this.requestRender();
944
+ }, !!this.projectPath);
945
+ return { handled: true, pushScreen: screen };
946
+ }
947
+ // Enter on Bindings tab — open binding screen for that skill
948
+ // (handled via createDetailScreen returning null + onOpenDetail for custom,
949
+ // but for binding items we open SkillBindingScreen)
950
+ // d — remove binding (Bindings tab only)
951
+ if (ch === 'd' && selectedItem.scope === 'binding' && selectedItem.bindingCommand && selectedItem.bindingScope) {
952
+ void this.removeOneBinding(selectedItem.bindingCommand, selectedItem.bindingScope);
953
+ return { handled: true, render: true };
954
+ }
955
+ // Actions below are for custom skills only
956
+ if (selectedItem.scope === 'sdk' || selectedItem.scope === 'binding')
957
+ return { handled: false };
958
+ // e — open skill detail (same as Enter, but explicit shortcut)
959
+ if (ch === 'e' && selectedItem.filePath && this.onOpenDetail) {
960
+ void this.onOpenDetail(selectedItem.filePath, selectedItem.name, selectedItem.scope, selectedItem.forkedFrom, selectedItem.boundCommands).then(async (modified) => {
961
+ if (modified)
962
+ await this.loadItems();
963
+ this.requestRender();
964
+ });
965
+ return { handled: true };
966
+ }
967
+ // Space — toggle enable/disable
968
+ if (data.toString() === ' ') {
969
+ void this.toggleSkill(selectedItem);
970
+ return { handled: true, render: true };
971
+ }
972
+ // d — delete
973
+ if (ch === 'd') {
974
+ const screen = new DeleteConfirmScreen(selectedItem, this.getStyles(), async () => {
975
+ await this.deleteSkill(selectedItem);
976
+ });
977
+ return { handled: true, pushScreen: screen };
978
+ }
979
+ // r — rename
980
+ if (ch === 'r') {
981
+ const scope = selectedItem.scope === 'project' ? 'project' : 'user';
982
+ const screen = new RenameScreen(selectedItem.name, scope, this.getStyles(), async (oldName, newName, scope) => {
983
+ await this.renameSkill(oldName, newName, scope);
984
+ });
985
+ return { handled: true, pushScreen: screen };
986
+ }
987
+ // v — validate
988
+ if (ch === 'v') {
989
+ void this.revalidateSkill(selectedItem);
990
+ return { handled: true, render: true };
991
+ }
992
+ return { handled: false };
993
+ }
994
+ // ===========================================================================
995
+ // Skill operations
996
+ // ===========================================================================
997
+ async createSkill(name, scope) {
998
+ const folder = getSkillFolder(name, scope);
999
+ if (await pathExists(folder))
1000
+ return; // Already exists
1001
+ await ensureSkillsDir(scope);
1002
+ await fs.mkdir(folder, { recursive: true });
1003
+ await fs.writeFile(getSkillFile(name, scope), buildScaffold(name, scope));
1004
+ // Reload items and trigger re-render
1005
+ await this.loadItems();
1006
+ this.requestRender();
1007
+ }
1008
+ async toggleSkill(item) {
1009
+ if (!item.filePath)
1010
+ return;
1011
+ const content = await fs.readFile(item.filePath, 'utf-8');
1012
+ const newEnabled = !item.enabled;
1013
+ const updated = setEnabledFlag(content, newEnabled);
1014
+ await fs.writeFile(item.filePath, updated);
1015
+ item.enabled = newEnabled;
1016
+ }
1017
+ async deleteSkill(item) {
1018
+ if (item.scope === 'sdk')
1019
+ return;
1020
+ const scope = item.scope === 'project' ? 'project' : 'user';
1021
+ const folder = getSkillFolder(item.name, scope);
1022
+ await fs.rm(folder, { recursive: true, force: true });
1023
+ await this.loadItems();
1024
+ this.requestRender();
1025
+ }
1026
+ async renameSkill(oldName, newName, scope) {
1027
+ const newFolder = getSkillFolder(newName, scope);
1028
+ if (await pathExists(newFolder))
1029
+ return; // Target exists
1030
+ await fs.rename(getSkillFolder(oldName, scope), newFolder);
1031
+ const newFile = getSkillFile(newName, scope);
1032
+ const content = await fs.readFile(newFile, 'utf-8');
1033
+ await fs.writeFile(newFile, replaceFrontmatterName(content, newName));
1034
+ await this.loadItems();
1035
+ this.requestRender();
1036
+ }
1037
+ async revalidateSkill(item) {
1038
+ if (!item.filePath)
1039
+ return;
1040
+ try {
1041
+ const content = await fs.readFile(item.filePath, 'utf-8');
1042
+ const parsed = parseSkillMarkdown(content);
1043
+ if (!parsed) {
1044
+ item.issues = ['Invalid frontmatter — required fields: name, description.'];
1045
+ }
1046
+ else {
1047
+ item.issues = validateSkill(parsed, item.name);
1048
+ }
1049
+ }
1050
+ catch {
1051
+ item.issues = ['Failed to read skill file.'];
1052
+ }
1053
+ }
1054
+ async forkSkill(sdkName, newName, scope) {
1055
+ const folder = getSkillFolder(newName, scope);
1056
+ if (await pathExists(folder))
1057
+ return;
1058
+ // Find the SDK skill
1059
+ const allSdk = [...platformSkills, ...builtinSkills];
1060
+ const sdkSkill = allSdk.find((s) => s.name === sdkName);
1061
+ if (!sdkSkill)
1062
+ return;
1063
+ const content = buildForkContent(sdkSkill, newName, getSDKVersion());
1064
+ await ensureSkillsDir(scope);
1065
+ await fs.mkdir(folder, { recursive: true });
1066
+ await fs.writeFile(getSkillFile(newName, scope), content);
1067
+ await this.loadItems();
1068
+ this.requestRender();
1069
+ }
1070
+ async removeOneBinding(command, scope) {
1071
+ const config = await readScopeConfig(scope);
1072
+ if (!config.slashCommands)
1073
+ return;
1074
+ const filtered = {};
1075
+ for (const [cmd, skill] of Object.entries(config.slashCommands)) {
1076
+ if (cmd !== command)
1077
+ filtered[cmd] = skill;
1078
+ }
1079
+ config.slashCommands = filtered;
1080
+ await writeScopeConfig(scope, config);
1081
+ await this.loadItems();
1082
+ this.requestRender();
1083
+ }
1084
+ }
1085
+ /** Get SDK version from package.json (best effort). */
1086
+ function getSDKVersion() {
1087
+ try {
1088
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
1089
+ const pkg = require('@compilr-dev/sdk/package.json');
1090
+ return pkg.version;
1091
+ }
1092
+ catch {
1093
+ return 'unknown';
1094
+ }
1095
+ }