@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.
- package/dist/commands-v2/handlers/core.js +10 -1
- package/dist/commands-v2/handlers/index.d.ts +1 -0
- package/dist/commands-v2/handlers/index.js +3 -0
- package/dist/commands-v2/handlers/settings.js +21 -5
- package/dist/commands-v2/handlers/skill.d.ts +10 -0
- package/dist/commands-v2/handlers/skill.js +63 -0
- package/dist/commands-v2/index.d.ts +1 -1
- package/dist/commands-v2/index.js +1 -1
- package/dist/commands-v2/registry.d.ts +4 -0
- package/dist/commands-v2/registry.js +19 -0
- package/dist/compilr-diff-companion.vsix +0 -0
- package/dist/index.js +8 -12
- package/dist/repl-helpers.d.ts +29 -1
- package/dist/repl-helpers.js +77 -7
- package/dist/repl-v2.js +29 -3
- package/dist/ui/conversation.js +1 -2
- package/dist/ui/markdown-renderer.d.ts +43 -0
- package/dist/ui/markdown-renderer.js +474 -0
- package/dist/ui/overlay/impl/artifact-detail-overlay-v2.js +1 -2
- package/dist/ui/overlay/impl/document-detail-overlay-v2.js +1 -2
- package/dist/ui/overlay/impl/help-overlay-v2.d.ts +7 -1
- package/dist/ui/overlay/impl/help-overlay-v2.js +19 -2
- package/dist/ui/overlay/impl/skill-detail-overlay-v2.d.ts +91 -0
- package/dist/ui/overlay/impl/skill-detail-overlay-v2.js +863 -0
- package/dist/ui/overlay/impl/skill-editor-overlay.d.ts +56 -0
- package/dist/ui/overlay/impl/skill-editor-overlay.js +493 -0
- package/dist/ui/overlay/impl/skills-overlay-v2.d.ts +83 -0
- package/dist/ui/overlay/impl/skills-overlay-v2.js +1095 -0
- package/dist/utils/skill-paths.d.ts +21 -0
- package/dist/utils/skill-paths.js +44 -0
- 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
|
+
}
|