@dealdeploy/skl 0.4.0 → 1.0.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/add.ts +55 -64
- package/index.ts +110 -972
- package/lib.test.ts +110 -41
- package/lib.ts +125 -38
- package/package.json +1 -1
- package/tui.test.ts +565 -0
- package/tui.ts +612 -0
- package/update.ts +102 -87
package/index.ts
CHANGED
|
@@ -1,29 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
TextRenderable,
|
|
7
|
-
ScrollBoxRenderable,
|
|
8
|
-
TextAttributes,
|
|
9
|
-
type KeyEvent,
|
|
10
|
-
} from "@opentui/core";
|
|
11
|
-
import {
|
|
12
|
-
readdirSync,
|
|
13
|
-
lstatSync,
|
|
14
|
-
readlinkSync,
|
|
15
|
-
symlinkSync,
|
|
16
|
-
unlinkSync,
|
|
17
|
-
mkdirSync,
|
|
18
|
-
existsSync,
|
|
19
|
-
rmSync,
|
|
20
|
-
renameSync,
|
|
21
|
-
cpSync,
|
|
22
|
-
readSync,
|
|
23
|
-
} from "fs";
|
|
24
|
-
import { join, resolve } from "path";
|
|
3
|
+
import { createCliRenderer } from "@opentui/core";
|
|
4
|
+
import { existsSync, rmSync, cpSync, mkdirSync, readdirSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
25
6
|
import { homedir } from "os";
|
|
26
|
-
import {
|
|
7
|
+
import { getCatalogSkills, getLockEntry, removeFromLock, catalogDir } from "./lib.ts";
|
|
8
|
+
import { createTui, type ColId } from "./tui.ts";
|
|
27
9
|
|
|
28
10
|
// @ts-ignore - bun supports JSON imports
|
|
29
11
|
const { version: VERSION } = await import("./package.json");
|
|
@@ -47,977 +29,133 @@ if (arg === "update") {
|
|
|
47
29
|
|
|
48
30
|
try {
|
|
49
31
|
|
|
50
|
-
// ──
|
|
32
|
+
// ── Migration: ~/dotfiles/skills → ~/.skl/catalog ───────────────────
|
|
33
|
+
const OLD_LIBRARY = join(homedir(), "dotfiles/skills");
|
|
34
|
+
const CATALOG = catalogDir();
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
console.error("Create it with: mkdir -p ~/dotfiles/skills");
|
|
59
|
-
process.exit(1);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function ellipsize(text: string, max: number): string {
|
|
63
|
-
if (max <= 0) return "";
|
|
64
|
-
if (text.length <= max) return text;
|
|
65
|
-
if (max === 1) return "…";
|
|
66
|
-
return `${text.slice(0, max - 1)}…`;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Safety: ensure skill dirs aren't symlinks pointing at the library itself
|
|
70
|
-
for (const [dir, label] of [[GLOBAL_DIR, "~/.agents/skills"], [GLOBAL_CLAUDE_DIR, "~/.claude/skills"]] as const) {
|
|
71
|
-
try {
|
|
72
|
-
if (lstatSync(dir).isSymbolicLink() && resolve(readlinkSync(dir)) === resolve(LIBRARY)) {
|
|
73
|
-
console.error(`ERROR: ${label} is a symlink to the library — it must be a real directory.`);
|
|
74
|
-
console.error(`Remove it and create a real directory: rm ${label} && mkdir ${label}`);
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
} catch {
|
|
78
|
-
// doesn't exist yet, that's fine — toggle will mkdir
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function isLibraryLink(dir: string, name: string): boolean {
|
|
83
|
-
const full = join(dir, name);
|
|
84
|
-
try {
|
|
85
|
-
return (
|
|
86
|
-
lstatSync(full).isSymbolicLink() &&
|
|
87
|
-
resolve(readlinkSync(full)) === resolve(join(LIBRARY, name))
|
|
88
|
-
);
|
|
89
|
-
} catch {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function nonLibraryExists(dir: string, name: string): boolean {
|
|
95
|
-
const full = join(dir, name);
|
|
96
|
-
try {
|
|
97
|
-
return existsSync(full) && !isLibraryLink(dir, name);
|
|
98
|
-
} catch {
|
|
99
|
-
return false;
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function isLocalDir(dir: string): boolean {
|
|
104
|
-
return dir !== GLOBAL_DIR && dir !== GLOBAL_CLAUDE_DIR;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function isEnabled(dir: string, name: string): boolean {
|
|
108
|
-
if (isLocalDir(dir)) return existsSync(join(dir, name));
|
|
109
|
-
return isLibraryLink(dir, name);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function isBlocked(dir: string, name: string): boolean {
|
|
113
|
-
if (isLocalDir(dir)) return false;
|
|
114
|
-
return nonLibraryExists(dir, name);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function toggle(dir: string, name: string): boolean {
|
|
118
|
-
const full = join(dir, name);
|
|
119
|
-
if (isLocalDir(dir)) {
|
|
120
|
-
// Copy-based toggle for project-local dirs
|
|
121
|
-
if (existsSync(full)) {
|
|
122
|
-
rmSync(full, { recursive: true, force: true });
|
|
123
|
-
return true;
|
|
124
|
-
}
|
|
125
|
-
// Remove any broken symlink left from old behavior
|
|
126
|
-
try { rmSync(full, { force: true }); } catch {}
|
|
127
|
-
mkdirSync(dir, { recursive: true });
|
|
128
|
-
cpSync(join(LIBRARY, name), full, { recursive: true });
|
|
129
|
-
return true;
|
|
130
|
-
}
|
|
131
|
-
// Symlink-based toggle for global dirs
|
|
132
|
-
if (isLibraryLink(dir, name)) {
|
|
133
|
-
unlinkSync(full);
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
if (existsSync(full)) {
|
|
137
|
-
return false;
|
|
138
|
-
}
|
|
139
|
-
mkdirSync(dir, { recursive: true });
|
|
140
|
-
symlinkSync(join(LIBRARY, name), full);
|
|
141
|
-
return true;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function findLocalDir(): string | null {
|
|
145
|
-
const dir = join(process.cwd(), ".agents/skills");
|
|
146
|
-
if (existsSync(dir)) return dir;
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function ensureLocalDir(): string {
|
|
151
|
-
if (localDir) return localDir;
|
|
152
|
-
const dir = join(process.cwd(), ".agents/skills");
|
|
153
|
-
mkdirSync(dir, { recursive: true });
|
|
154
|
-
localDir = dir;
|
|
155
|
-
localLabel = dir.replace(homedir(), "~");
|
|
156
|
-
colLocal.content = ellipsize(localLabel, COL_W - 1);
|
|
157
|
-
refreshAll();
|
|
158
|
-
return dir;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function findLocalClaudeDir(): string | null {
|
|
162
|
-
const dir = join(process.cwd(), ".claude/skills");
|
|
163
|
-
if (existsSync(dir)) return dir;
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function ensureLocalClaudeDir(): string {
|
|
168
|
-
if (localClaudeDir) return localClaudeDir;
|
|
169
|
-
const dir = join(process.cwd(), ".claude/skills");
|
|
170
|
-
mkdirSync(dir, { recursive: true });
|
|
171
|
-
localClaudeDir = dir;
|
|
172
|
-
localClaudeLabel = dir.replace(homedir(), "~");
|
|
173
|
-
colLocalClaude.content = ellipsize(localClaudeLabel, COL_W - 1);
|
|
174
|
-
refreshAll();
|
|
175
|
-
return dir;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// ── Migration check ──────────────────────────────────────────────────
|
|
179
|
-
{
|
|
180
|
-
// Only migrate global dirs — project-local dirs use copies, not symlinks
|
|
181
|
-
const dirsToCheck: [string, string][] = [
|
|
182
|
-
[GLOBAL_DIR, "~/.agents/skills"],
|
|
183
|
-
[GLOBAL_CLAUDE_DIR, "~/.claude/skills"],
|
|
184
|
-
];
|
|
185
|
-
|
|
186
|
-
const migrations: { dir: string; label: string; name: string; conflict: boolean }[] = [];
|
|
187
|
-
|
|
188
|
-
for (const [dir, label] of dirsToCheck) {
|
|
189
|
-
try {
|
|
190
|
-
for (const name of readdirSync(dir)) {
|
|
191
|
-
if (!isLibraryLink(dir, name)) {
|
|
192
|
-
migrations.push({ dir, label, name, conflict: existsSync(join(LIBRARY, name)) });
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
} catch {
|
|
196
|
-
// dir doesn't exist
|
|
197
|
-
}
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
if (migrations.length > 0) {
|
|
201
|
-
console.log("\nFound skills not managed by library:");
|
|
202
|
-
for (const m of migrations) {
|
|
203
|
-
console.log(` ${m.label}/${m.name}${m.conflict ? " (already in library)" : ""}`);
|
|
204
|
-
}
|
|
205
|
-
process.stdout.write(`\nMigrate to ${LIBRARY.replace(homedir(), "~")} and replace with symlinks? [y/N] `);
|
|
206
|
-
|
|
207
|
-
const buf = new Uint8Array(100);
|
|
208
|
-
const n = readSync(0, buf);
|
|
209
|
-
const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
|
|
210
|
-
|
|
211
|
-
if (answer === "y" || answer === "yes") {
|
|
212
|
-
let migrated = 0;
|
|
213
|
-
for (const m of migrations) {
|
|
214
|
-
const src = join(m.dir, m.name);
|
|
215
|
-
const dest = join(LIBRARY, m.name);
|
|
216
|
-
if (m.conflict) {
|
|
217
|
-
// Library already has this skill — remove local and symlink to library
|
|
218
|
-
rmSync(src, { recursive: true, force: true });
|
|
219
|
-
symlinkSync(dest, src);
|
|
220
|
-
console.log(` replaced ${m.name} (library version kept)`);
|
|
221
|
-
} else {
|
|
222
|
-
try {
|
|
223
|
-
renameSync(src, dest);
|
|
224
|
-
} catch {
|
|
225
|
-
// cross-device fallback
|
|
226
|
-
cpSync(src, dest, { recursive: true });
|
|
227
|
-
rmSync(src, { recursive: true, force: true });
|
|
228
|
-
}
|
|
229
|
-
symlinkSync(dest, src);
|
|
230
|
-
console.log(` migrated ${m.name}`);
|
|
231
|
-
}
|
|
232
|
-
migrated++;
|
|
233
|
-
}
|
|
234
|
-
console.log(`\n${migrated} migrated\n`);
|
|
235
|
-
} else {
|
|
236
|
-
console.log("Skipping migration.\n");
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// ── Local symlink → copy conversion ─────────────────────────────────
|
|
242
|
-
{
|
|
243
|
-
const localDirsToCheck: [string, string][] = [];
|
|
244
|
-
const ld = findLocalDir();
|
|
245
|
-
if (ld) localDirsToCheck.push([ld, ld.replace(homedir(), "~")]);
|
|
246
|
-
const lcd = findLocalClaudeDir();
|
|
247
|
-
if (lcd) localDirsToCheck.push([lcd, lcd.replace(homedir(), "~")]);
|
|
248
|
-
|
|
249
|
-
const localSymlinks: { dir: string; label: string; name: string; target: string }[] = [];
|
|
250
|
-
|
|
251
|
-
for (const [dir, label] of localDirsToCheck) {
|
|
36
|
+
if (!existsSync(CATALOG) && existsSync(OLD_LIBRARY)) {
|
|
37
|
+
console.log("Migrating skills from ~/dotfiles/skills to ~/.skl/catalog...");
|
|
38
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
39
|
+
for (const name of readdirSync(OLD_LIBRARY)) {
|
|
40
|
+
const src = join(OLD_LIBRARY, name);
|
|
41
|
+
const dest = join(CATALOG, name);
|
|
252
42
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
if (lstatSync(full).isSymbolicLink()) {
|
|
257
|
-
localSymlinks.push({ dir, label, name, target: readlinkSync(full) });
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
260
|
-
}
|
|
43
|
+
cpSync(src, dest, { recursive: true });
|
|
44
|
+
console.log(` ${name}`);
|
|
261
45
|
} catch {}
|
|
262
46
|
}
|
|
263
|
-
|
|
264
|
-
if (localSymlinks.length > 0) {
|
|
265
|
-
console.log("\nFound symlinked skills in project-local dirs:");
|
|
266
|
-
for (const s of localSymlinks) {
|
|
267
|
-
console.log(` ${s.label}/${s.name} → ${s.target}`);
|
|
268
|
-
}
|
|
269
|
-
process.stdout.write("\nConvert to local copies? (recommended for portability) [y/N] ");
|
|
270
|
-
|
|
271
|
-
const buf = new Uint8Array(100);
|
|
272
|
-
const n = readSync(0, buf);
|
|
273
|
-
const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
|
|
274
|
-
|
|
275
|
-
if (answer === "y" || answer === "yes") {
|
|
276
|
-
let converted = 0;
|
|
277
|
-
for (const s of localSymlinks) {
|
|
278
|
-
const full = join(s.dir, s.name);
|
|
279
|
-
const libPath = join(LIBRARY, s.name);
|
|
280
|
-
unlinkSync(full);
|
|
281
|
-
if (existsSync(libPath)) {
|
|
282
|
-
cpSync(libPath, full, { recursive: true });
|
|
283
|
-
console.log(` converted ${s.name}`);
|
|
284
|
-
converted++;
|
|
285
|
-
} else {
|
|
286
|
-
console.log(` removed ${s.name} (not in library)`);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
console.log(`\n${converted} converted\n`);
|
|
290
|
-
} else {
|
|
291
|
-
console.log("Skipping conversion.\n");
|
|
292
|
-
}
|
|
293
|
-
}
|
|
47
|
+
console.log("Done. You can remove ~/dotfiles/skills when ready.\n");
|
|
294
48
|
}
|
|
295
49
|
|
|
296
|
-
|
|
297
|
-
{
|
|
298
|
-
const localDirsToScan: string[] = [];
|
|
299
|
-
const ld = findLocalDir();
|
|
300
|
-
if (ld) localDirsToScan.push(ld);
|
|
301
|
-
const lcd = findLocalClaudeDir();
|
|
302
|
-
if (lcd) localDirsToScan.push(lcd);
|
|
303
|
-
|
|
304
|
-
const orphans = findOrphanSkills(localDirsToScan, LIBRARY);
|
|
305
|
-
|
|
306
|
-
if (orphans.length > 0) {
|
|
307
|
-
console.log("\nFound skills in this repo not in your library:");
|
|
308
|
-
for (const o of orphans) {
|
|
309
|
-
console.log(` ${o.dir.replace(homedir(), "~")}/${o.name}`);
|
|
310
|
-
}
|
|
311
|
-
process.stdout.write(`\nCopy to ${LIBRARY.replace(homedir(), "~")}? [y/N] `);
|
|
312
|
-
|
|
313
|
-
const buf = new Uint8Array(100);
|
|
314
|
-
const n = readSync(0, buf);
|
|
315
|
-
const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
|
|
316
|
-
|
|
317
|
-
if (answer === "y" || answer === "yes") {
|
|
318
|
-
const results = adoptSkills(orphans, LIBRARY);
|
|
319
|
-
for (const r of results) {
|
|
320
|
-
console.log(` copied ${r.name}`);
|
|
321
|
-
}
|
|
322
|
-
console.log(`\n${results.filter(r => r.copied).length} added to library\n`);
|
|
323
|
-
} else {
|
|
324
|
-
console.log("Skipping.\n");
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
const allSkills = readdirSync(LIBRARY).sort();
|
|
330
|
-
|
|
331
|
-
// ── State ───────────────────────────────────────────────────────────
|
|
332
|
-
|
|
333
|
-
type ColId = "global" | "globalClaude" | "local" | "localClaude";
|
|
334
|
-
|
|
335
|
-
let cursor = 0; // index into filteredIndices
|
|
336
|
-
let cursorCol: ColId = "global";
|
|
337
|
-
let focusArea: "search" | "grid" = "search";
|
|
338
|
-
let localDir = findLocalDir();
|
|
339
|
-
let localClaudeDir = findLocalClaudeDir();
|
|
340
|
-
let statusTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
341
|
-
let pendingDelete: number | null = null; // allSkills index awaiting confirmation
|
|
342
|
-
const deletedSkills = new Set<number>();
|
|
343
|
-
let searchQuery = "";
|
|
344
|
-
let filteredIndices: number[] = allSkills.map((_, i) => i);
|
|
345
|
-
const COL_ORDER: ColId[] = ["global", "globalClaude", "local", "localClaude"];
|
|
346
|
-
|
|
347
|
-
// ── Colors ──────────────────────────────────────────────────────────
|
|
348
|
-
|
|
349
|
-
const C = {
|
|
350
|
-
bg: "#1a1a2e",
|
|
351
|
-
rowBg: "#1a1a2e",
|
|
352
|
-
rowAltBg: "#1f1f38",
|
|
353
|
-
cursorBg: "#2a2a5a",
|
|
354
|
-
accentBg: "#3a3a7a",
|
|
355
|
-
border: "#444477",
|
|
356
|
-
fg: "#ccccdd",
|
|
357
|
-
fgDim: "#666688",
|
|
358
|
-
checked: "#66ff88",
|
|
359
|
-
unchecked: "#555566",
|
|
360
|
-
warning: "#ffaa44",
|
|
361
|
-
accent: "#8888ff",
|
|
362
|
-
title: "#aaaaff",
|
|
363
|
-
footer: "#888899",
|
|
364
|
-
statusOk: "#66ff88",
|
|
365
|
-
statusErr: "#ff6666",
|
|
366
|
-
search: "#ffdd55",
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
// ── Checkbox helpers ────────────────────────────────────────────────
|
|
370
|
-
|
|
371
|
-
function checkboxStr(checked: boolean, blocked: boolean): string {
|
|
372
|
-
if (blocked) return "[!]";
|
|
373
|
-
return checked ? "[x]" : "[ ]";
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function checkboxColor(checked: boolean, blocked: boolean, active: boolean): string {
|
|
377
|
-
if (blocked) return C.warning;
|
|
378
|
-
if (active) return C.accent;
|
|
379
|
-
return checked ? C.checked : C.unchecked;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// ── Build TUI ───────────────────────────────────────────────────────
|
|
383
|
-
|
|
384
|
-
const renderer = await createCliRenderer({ exitOnCtrlC: true });
|
|
385
|
-
|
|
386
|
-
const outer = new BoxRenderable(renderer, {
|
|
387
|
-
id: "outer",
|
|
388
|
-
width: "100%",
|
|
389
|
-
height: "100%",
|
|
390
|
-
flexDirection: "column",
|
|
391
|
-
backgroundColor: C.bg,
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
// Search bar (lives inside scrollBox as the first row)
|
|
395
|
-
const searchRow = new BoxRenderable(renderer, {
|
|
396
|
-
id: "search-row",
|
|
397
|
-
flexDirection: "row",
|
|
398
|
-
height: 1,
|
|
399
|
-
width: "100%",
|
|
400
|
-
paddingLeft: 1,
|
|
401
|
-
backgroundColor: C.rowBg,
|
|
402
|
-
});
|
|
403
|
-
const searchBar = new TextRenderable(renderer, {
|
|
404
|
-
id: "search-bar",
|
|
405
|
-
content: "",
|
|
406
|
-
fg: C.search,
|
|
407
|
-
height: 1,
|
|
408
|
-
});
|
|
409
|
-
searchRow.add(searchBar);
|
|
410
|
-
|
|
411
|
-
const colHeaderRow = new BoxRenderable(renderer, {
|
|
412
|
-
id: "col-header-row",
|
|
413
|
-
flexDirection: "row",
|
|
414
|
-
height: 1,
|
|
415
|
-
paddingLeft: 1,
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
let COL_W = 14;
|
|
419
|
-
let NAME_W = 34;
|
|
420
|
-
|
|
421
|
-
function calcWidths() {
|
|
422
|
-
const w = process.stdout.columns || 80;
|
|
423
|
-
COL_W = Math.max(5, Math.min(14, Math.floor((w - 20) / 4)));
|
|
424
|
-
NAME_W = Math.min(34, Math.max(15, w - COL_W * 4 - 1));
|
|
425
|
-
}
|
|
426
|
-
calcWidths();
|
|
427
|
-
|
|
428
|
-
const colName = new TextRenderable(renderer, {
|
|
429
|
-
id: "col-name",
|
|
430
|
-
content: "Skill",
|
|
431
|
-
fg: C.fgDim,
|
|
432
|
-
attributes: TextAttributes.BOLD,
|
|
433
|
-
width: NAME_W,
|
|
434
|
-
});
|
|
435
|
-
const colGlobal = new TextRenderable(renderer, {
|
|
436
|
-
id: "col-global",
|
|
437
|
-
content: "~/.agents",
|
|
438
|
-
fg: C.fgDim,
|
|
439
|
-
attributes: TextAttributes.BOLD,
|
|
440
|
-
width: COL_W,
|
|
441
|
-
});
|
|
442
|
-
const colGlobalClaude = new TextRenderable(renderer, {
|
|
443
|
-
id: "col-global-claude",
|
|
444
|
-
content: "~/.claude",
|
|
445
|
-
fg: C.fgDim,
|
|
446
|
-
attributes: TextAttributes.BOLD,
|
|
447
|
-
width: COL_W,
|
|
448
|
-
});
|
|
449
|
-
let localLabel = localDir ? localDir.replace(homedir(), "~") : ".agents/skills";
|
|
450
|
-
const colLocal = new TextRenderable(renderer, {
|
|
451
|
-
id: "col-local",
|
|
452
|
-
content: ellipsize(localLabel, COL_W - 1),
|
|
453
|
-
fg: C.fgDim,
|
|
454
|
-
attributes: TextAttributes.BOLD,
|
|
455
|
-
width: COL_W,
|
|
456
|
-
});
|
|
457
|
-
let localClaudeLabel = localClaudeDir ? localClaudeDir.replace(homedir(), "~") : ".claude/skills";
|
|
458
|
-
const colLocalClaude = new TextRenderable(renderer, {
|
|
459
|
-
id: "col-local-claude",
|
|
460
|
-
content: ellipsize(localClaudeLabel, COL_W - 1),
|
|
461
|
-
fg: C.fgDim,
|
|
462
|
-
attributes: TextAttributes.BOLD,
|
|
463
|
-
width: COL_W,
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
colHeaderRow.add(colName);
|
|
467
|
-
colHeaderRow.add(colGlobal);
|
|
468
|
-
colHeaderRow.add(colGlobalClaude);
|
|
469
|
-
colHeaderRow.add(colLocal);
|
|
470
|
-
colHeaderRow.add(colLocalClaude);
|
|
471
|
-
|
|
472
|
-
const sep = new TextRenderable(renderer, {
|
|
473
|
-
id: "sep",
|
|
474
|
-
content: "─".repeat(200),
|
|
475
|
-
fg: C.border,
|
|
476
|
-
width: "100%",
|
|
477
|
-
height: 1,
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
const scrollBox = new ScrollBoxRenderable(renderer, {
|
|
481
|
-
id: "skill-list",
|
|
482
|
-
flexGrow: 1,
|
|
483
|
-
width: "100%",
|
|
484
|
-
});
|
|
485
|
-
|
|
486
|
-
type RowRefs = {
|
|
487
|
-
row: BoxRenderable;
|
|
488
|
-
nameText: TextRenderable;
|
|
489
|
-
globalText: TextRenderable;
|
|
490
|
-
globalClaudeText: TextRenderable;
|
|
491
|
-
localText: TextRenderable;
|
|
492
|
-
localClaudeText: TextRenderable;
|
|
493
|
-
};
|
|
494
|
-
const rows: RowRefs[] = [];
|
|
495
|
-
|
|
496
|
-
scrollBox.add(searchRow);
|
|
497
|
-
|
|
498
|
-
for (let i = 0; i < allSkills.length; i++) {
|
|
499
|
-
const skill = allSkills[i];
|
|
500
|
-
|
|
501
|
-
const row = new BoxRenderable(renderer, {
|
|
502
|
-
id: `row-${i}`,
|
|
503
|
-
flexDirection: "row",
|
|
504
|
-
height: 1,
|
|
505
|
-
width: "100%",
|
|
506
|
-
paddingLeft: 1,
|
|
507
|
-
backgroundColor: i % 2 === 0 ? C.rowBg : C.rowAltBg,
|
|
508
|
-
});
|
|
50
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
509
51
|
|
|
510
|
-
|
|
511
|
-
id: `name-${i}`,
|
|
512
|
-
content: ` ${ellipsize(skill, NAME_W - 3)}`,
|
|
513
|
-
fg: C.fg,
|
|
514
|
-
width: NAME_W,
|
|
515
|
-
});
|
|
52
|
+
// ── Load installed state from skills CLI ─────────────────────────────
|
|
516
53
|
|
|
517
|
-
|
|
518
|
-
const
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
const globalClaudeText = new TextRenderable(renderer, {
|
|
529
|
-
id: `global-claude-${i}`,
|
|
530
|
-
content: checkboxStr(gcChecked, gcBlocked),
|
|
531
|
-
fg: checkboxColor(gcChecked, gcBlocked, false),
|
|
532
|
-
width: COL_W,
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
const lChecked = localDir ? isEnabled(localDir, skill) : false;
|
|
536
|
-
const lBlocked = localDir ? isBlocked(localDir, skill) : false;
|
|
537
|
-
const localText = new TextRenderable(renderer, {
|
|
538
|
-
id: `local-${i}`,
|
|
539
|
-
content: localDir ? checkboxStr(lChecked, lBlocked) : " -",
|
|
540
|
-
fg: localDir ? checkboxColor(lChecked, lBlocked, false) : C.fgDim,
|
|
541
|
-
width: COL_W,
|
|
542
|
-
});
|
|
543
|
-
|
|
544
|
-
const lcChecked = localClaudeDir ? isEnabled(localClaudeDir, skill) : false;
|
|
545
|
-
const lcBlocked = localClaudeDir ? isBlocked(localClaudeDir, skill) : false;
|
|
546
|
-
const localClaudeText = new TextRenderable(renderer, {
|
|
547
|
-
id: `local-claude-${i}`,
|
|
548
|
-
content: localClaudeDir ? checkboxStr(lcChecked, lcBlocked) : " -",
|
|
549
|
-
fg: localClaudeDir ? checkboxColor(lcChecked, lcBlocked, false) : C.fgDim,
|
|
550
|
-
width: COL_W,
|
|
551
|
-
});
|
|
552
|
-
|
|
553
|
-
row.add(nameText);
|
|
554
|
-
row.add(globalText);
|
|
555
|
-
row.add(globalClaudeText);
|
|
556
|
-
row.add(localText);
|
|
557
|
-
row.add(localClaudeText);
|
|
558
|
-
scrollBox.add(row);
|
|
559
|
-
rows.push({ row, nameText, globalText, globalClaudeText, localText, localClaudeText });
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
const footerSep = new TextRenderable(renderer, {
|
|
563
|
-
id: "footer-sep",
|
|
564
|
-
content: "─".repeat(200),
|
|
565
|
-
fg: C.border,
|
|
566
|
-
width: "100%",
|
|
567
|
-
height: 1,
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
const footer = new TextRenderable(renderer, {
|
|
571
|
-
id: "footer",
|
|
572
|
-
content: " ↑↓ move ←→/tab col enter toggle a all e edit d del / search q/esc quit",
|
|
573
|
-
fg: C.footer,
|
|
574
|
-
height: 1,
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
const statusLine = new TextRenderable(renderer, {
|
|
578
|
-
id: "status",
|
|
579
|
-
content: "",
|
|
580
|
-
fg: C.statusOk,
|
|
581
|
-
height: 1,
|
|
582
|
-
});
|
|
583
|
-
|
|
584
|
-
outer.add(colHeaderRow);
|
|
585
|
-
outer.add(sep);
|
|
586
|
-
outer.add(scrollBox);
|
|
587
|
-
outer.add(footerSep);
|
|
588
|
-
outer.add(footer);
|
|
589
|
-
outer.add(statusLine);
|
|
590
|
-
renderer.root.add(outer);
|
|
591
|
-
|
|
592
|
-
// ── Search / filter ─────────────────────────────────────────────────
|
|
593
|
-
|
|
594
|
-
function applyFilter() {
|
|
595
|
-
const term = searchQuery.toLowerCase();
|
|
596
|
-
filteredIndices = [];
|
|
597
|
-
for (let i = 0; i < allSkills.length; i++) {
|
|
598
|
-
if (deletedSkills.has(i)) {
|
|
599
|
-
rows[i].row.visible = false;
|
|
600
|
-
continue;
|
|
601
|
-
}
|
|
602
|
-
const match = !term || allSkills[i].toLowerCase().includes(term);
|
|
603
|
-
rows[i].row.visible = match;
|
|
604
|
-
if (match) filteredIndices.push(i);
|
|
605
|
-
}
|
|
606
|
-
// clamp cursor
|
|
607
|
-
if (filteredIndices.length === 0) {
|
|
608
|
-
cursor = 0;
|
|
609
|
-
} else if (cursor >= filteredIndices.length) {
|
|
610
|
-
cursor = filteredIndices.length - 1;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
function updateSearchBar() {
|
|
615
|
-
if (focusArea === "search") {
|
|
616
|
-
searchBar.content = `▸ ${searchQuery}█`;
|
|
617
|
-
searchBar.fg = C.search;
|
|
618
|
-
searchRow.backgroundColor = C.cursorBg;
|
|
619
|
-
} else {
|
|
620
|
-
searchBar.content = searchQuery ? ` ${searchQuery}` : ` search...`;
|
|
621
|
-
searchBar.fg = C.fgDim;
|
|
622
|
-
searchRow.backgroundColor = C.rowBg;
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// ── Update display ──────────────────────────────────────────────────
|
|
627
|
-
|
|
628
|
-
function currentSkillIndex(): number | null {
|
|
629
|
-
return filteredIndices.length > 0 ? filteredIndices[cursor] : null;
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
function updateRow(i: number) {
|
|
633
|
-
const skill = allSkills[i];
|
|
634
|
-
const r = rows[i];
|
|
635
|
-
const ci = currentSkillIndex();
|
|
636
|
-
const isCursor = ci === i && focusArea === "grid";
|
|
637
|
-
|
|
638
|
-
const visPos = filteredIndices.indexOf(i);
|
|
639
|
-
const baseBg = visPos % 2 === 0 ? C.rowBg : C.rowAltBg;
|
|
640
|
-
r.row.backgroundColor = isCursor ? C.cursorBg : baseBg;
|
|
641
|
-
|
|
642
|
-
const pointer = isCursor ? "▸" : " ";
|
|
643
|
-
r.nameText.content = `${pointer} ${ellipsize(skill, NAME_W - 3)}`;
|
|
644
|
-
r.nameText.fg = isCursor ? "#ffffff" : C.fg;
|
|
645
|
-
r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
646
|
-
|
|
647
|
-
const gChecked = isLibraryLink(GLOBAL_DIR, skill);
|
|
648
|
-
const gBlocked = nonLibraryExists(GLOBAL_DIR, skill);
|
|
649
|
-
const gActive = isCursor && cursorCol === "global";
|
|
650
|
-
r.globalText.content = checkboxStr(gChecked, gBlocked);
|
|
651
|
-
r.globalText.fg = checkboxColor(gChecked, gBlocked, gActive);
|
|
652
|
-
r.globalText.bg = gActive ? C.accentBg : undefined;
|
|
653
|
-
r.globalText.attributes = gActive ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
654
|
-
|
|
655
|
-
const gcChecked = isLibraryLink(GLOBAL_CLAUDE_DIR, skill);
|
|
656
|
-
const gcBlocked = nonLibraryExists(GLOBAL_CLAUDE_DIR, skill);
|
|
657
|
-
const gcActive = isCursor && cursorCol === "globalClaude";
|
|
658
|
-
r.globalClaudeText.content = checkboxStr(gcChecked, gcBlocked);
|
|
659
|
-
r.globalClaudeText.fg = checkboxColor(gcChecked, gcBlocked, gcActive);
|
|
660
|
-
r.globalClaudeText.bg = gcActive ? C.accentBg : undefined;
|
|
661
|
-
r.globalClaudeText.attributes = gcActive ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
662
|
-
|
|
663
|
-
const lChecked = localDir ? isEnabled(localDir, skill) : false;
|
|
664
|
-
const lBlocked = localDir ? isBlocked(localDir, skill) : false;
|
|
665
|
-
const lActive = isCursor && cursorCol === "local";
|
|
666
|
-
if (localDir) {
|
|
667
|
-
r.localText.content = checkboxStr(lChecked, lBlocked);
|
|
668
|
-
r.localText.fg = checkboxColor(lChecked, lBlocked, lActive);
|
|
669
|
-
r.localText.bg = lActive ? C.accentBg : undefined;
|
|
670
|
-
r.localText.attributes = lActive ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
671
|
-
} else {
|
|
672
|
-
r.localText.content = " -";
|
|
673
|
-
r.localText.fg = C.fgDim;
|
|
674
|
-
r.localText.bg = lActive ? C.accentBg : undefined;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const lcChecked = localClaudeDir ? isEnabled(localClaudeDir, skill) : false;
|
|
678
|
-
const lcBlocked = localClaudeDir ? isBlocked(localClaudeDir, skill) : false;
|
|
679
|
-
const lcActive = isCursor && cursorCol === "localClaude";
|
|
680
|
-
if (localClaudeDir) {
|
|
681
|
-
r.localClaudeText.content = checkboxStr(lcChecked, lcBlocked);
|
|
682
|
-
r.localClaudeText.fg = checkboxColor(lcChecked, lcBlocked, lcActive);
|
|
683
|
-
r.localClaudeText.bg = lcActive ? C.accentBg : undefined;
|
|
684
|
-
r.localClaudeText.attributes = lcActive ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
685
|
-
} else {
|
|
686
|
-
r.localClaudeText.content = " -";
|
|
687
|
-
r.localClaudeText.fg = C.fgDim;
|
|
688
|
-
r.localClaudeText.bg = lcActive ? C.accentBg : undefined;
|
|
689
|
-
}
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function setStatus(msg: string, color: string) {
|
|
693
|
-
statusLine.content = ` ${msg}`;
|
|
694
|
-
statusLine.fg = color;
|
|
695
|
-
if (statusTimeout) clearTimeout(statusTimeout);
|
|
696
|
-
statusTimeout = setTimeout(() => {
|
|
697
|
-
statusLine.content = "";
|
|
698
|
-
}, 3000);
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
function relayout() {
|
|
702
|
-
calcWidths();
|
|
703
|
-
colName.width = NAME_W;
|
|
704
|
-
colGlobal.width = COL_W;
|
|
705
|
-
colGlobalClaude.width = COL_W;
|
|
706
|
-
colLocal.width = COL_W;
|
|
707
|
-
colLocal.content = ellipsize(localLabel, COL_W - 1);
|
|
708
|
-
colLocalClaude.width = COL_W;
|
|
709
|
-
colLocalClaude.content = ellipsize(localClaudeLabel, COL_W - 1);
|
|
710
|
-
for (let i = 0; i < allSkills.length; i++) {
|
|
711
|
-
const r = rows[i];
|
|
712
|
-
r.nameText.width = NAME_W;
|
|
713
|
-
r.globalText.width = COL_W;
|
|
714
|
-
r.globalClaudeText.width = COL_W;
|
|
715
|
-
r.localText.width = COL_W;
|
|
716
|
-
r.localClaudeText.width = COL_W;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
process.stdout.on("resize", () => {
|
|
721
|
-
relayout();
|
|
722
|
-
refreshAll();
|
|
723
|
-
});
|
|
724
|
-
|
|
725
|
-
function refreshAll() {
|
|
726
|
-
for (const i of filteredIndices) updateRow(i);
|
|
727
|
-
updateSearchBar();
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
refreshAll();
|
|
731
|
-
|
|
732
|
-
// ── Scrolling helper ────────────────────────────────────────────────
|
|
733
|
-
|
|
734
|
-
function ensureVisible() {
|
|
735
|
-
if (focusArea === "search") {
|
|
736
|
-
scrollBox.scrollTo(0);
|
|
737
|
-
} else {
|
|
738
|
-
// +1 offset because searchRow is child 0 in scrollBox
|
|
739
|
-
scrollBox.scrollTo(Math.max(0, cursor + 1 - 2));
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
|
|
743
|
-
// ── Toggle all in column ────────────────────────────────────────────
|
|
744
|
-
|
|
745
|
-
function toggleAllColumn(col: ColId) {
|
|
746
|
-
let dir: string;
|
|
747
|
-
let dirLabel: string;
|
|
748
|
-
switch (col) {
|
|
749
|
-
case "global": dir = GLOBAL_DIR; dirLabel = "~/.agents"; break;
|
|
750
|
-
case "globalClaude": dir = GLOBAL_CLAUDE_DIR; dirLabel = "~/.claude"; break;
|
|
751
|
-
case "local": dir = ensureLocalDir(); dirLabel = localLabel; break;
|
|
752
|
-
case "localClaude": dir = ensureLocalClaudeDir(); dirLabel = localClaudeLabel; break;
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// Determine intent: if majority of visible skills are linked, unlink all; otherwise link all
|
|
756
|
-
let linkedCount = 0;
|
|
757
|
-
for (const i of filteredIndices) {
|
|
758
|
-
if (isEnabled(dir, allSkills[i])) linkedCount++;
|
|
759
|
-
}
|
|
760
|
-
const shouldLink = linkedCount <= filteredIndices.length / 2;
|
|
761
|
-
let changed = 0;
|
|
762
|
-
let skipped = 0;
|
|
763
|
-
|
|
764
|
-
for (const i of filteredIndices) {
|
|
765
|
-
const skill = allSkills[i];
|
|
766
|
-
const isLinked = isEnabled(dir, skill);
|
|
767
|
-
if (shouldLink && !isLinked) {
|
|
768
|
-
if (toggle(dir, skill)) changed++;
|
|
769
|
-
else skipped++;
|
|
770
|
-
} else if (!shouldLink && isLinked) {
|
|
771
|
-
if (toggle(dir, skill)) changed++;
|
|
54
|
+
async function listInstalled(global: boolean): Promise<Set<string>> {
|
|
55
|
+
const args = ["npx", "-y", "skills", "list", "--json"];
|
|
56
|
+
if (global) args.push("-g");
|
|
57
|
+
try {
|
|
58
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
59
|
+
const out = await new Response(proc.stdout).text();
|
|
60
|
+
const code = await proc.exited;
|
|
61
|
+
if (code !== 0) return new Set();
|
|
62
|
+
const data = JSON.parse(out.trim());
|
|
63
|
+
if (Array.isArray(data)) {
|
|
64
|
+
return new Set(data.map((s: { name: string }) => s.name));
|
|
772
65
|
}
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
const action = shouldLink ? "linked" : "unlinked";
|
|
776
|
-
let msg = `${action} ${changed} skills in ${dirLabel}`;
|
|
777
|
-
if (skipped) msg += ` (${skipped} skipped)`;
|
|
778
|
-
setStatus(msg, shouldLink ? C.statusOk : C.statusErr);
|
|
66
|
+
} catch {}
|
|
67
|
+
return new Set();
|
|
779
68
|
}
|
|
780
69
|
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const editor = process.env.EDITOR || "nvim";
|
|
788
|
-
const proc = Bun.spawn([editor, skillPath], {
|
|
789
|
-
stdio: ["inherit", "inherit", "inherit"],
|
|
790
|
-
});
|
|
791
|
-
await proc.exited;
|
|
792
|
-
// Re-exec so the TUI picks up any changes
|
|
793
|
-
const self = Bun.spawn(["bun", ...process.argv.slice(1)], {
|
|
794
|
-
stdio: ["inherit", "inherit", "inherit"],
|
|
795
|
-
});
|
|
796
|
-
await self.exited;
|
|
797
|
-
process.exit(0);
|
|
798
|
-
}
|
|
70
|
+
process.stdout.write("Loading installed skills...");
|
|
71
|
+
const [globalInstalled, localInstalled] = await Promise.all([
|
|
72
|
+
listInstalled(true),
|
|
73
|
+
listInstalled(false),
|
|
74
|
+
]);
|
|
75
|
+
console.log(" done");
|
|
799
76
|
|
|
800
|
-
// ──
|
|
77
|
+
// ── Build add/remove args ────────────────────────────────────────────
|
|
801
78
|
|
|
802
|
-
function
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
79
|
+
function buildAddArgs(name: string, global: boolean): string[] {
|
|
80
|
+
const lockEntry = getLockEntry(name);
|
|
81
|
+
if (lockEntry) {
|
|
82
|
+
const args = ["add", lockEntry.sourceUrl, "--skill", name, "-y"];
|
|
83
|
+
if (global) args.push("-g");
|
|
84
|
+
return args;
|
|
806
85
|
}
|
|
86
|
+
const args = ["add", join(CATALOG, name), "-y"];
|
|
87
|
+
if (global) args.push("-g");
|
|
88
|
+
return args;
|
|
807
89
|
}
|
|
808
90
|
|
|
809
|
-
|
|
810
|
-
const skill = allSkills[idx];
|
|
811
|
-
|
|
812
|
-
// Remove from all skill dirs (symlinks for global, copies for local)
|
|
813
|
-
if (isLibraryLink(GLOBAL_DIR, skill)) unlinkSync(join(GLOBAL_DIR, skill));
|
|
814
|
-
if (isLibraryLink(GLOBAL_CLAUDE_DIR, skill)) unlinkSync(join(GLOBAL_CLAUDE_DIR, skill));
|
|
815
|
-
if (localDir && existsSync(join(localDir, skill))) rmSync(join(localDir, skill), { recursive: true, force: true });
|
|
816
|
-
if (localClaudeDir && existsSync(join(localClaudeDir, skill))) rmSync(join(localClaudeDir, skill), { recursive: true, force: true });
|
|
817
|
-
|
|
818
|
-
// Remove from library
|
|
819
|
-
rmSync(join(LIBRARY, skill), { recursive: true, force: true });
|
|
820
|
-
|
|
821
|
-
// Mark deleted and hide
|
|
822
|
-
deletedSkills.add(idx);
|
|
823
|
-
rows[idx].row.visible = false;
|
|
824
|
-
pendingDelete = null;
|
|
825
|
-
|
|
826
|
-
applyFilter();
|
|
827
|
-
setStatus(`🗑 ${skill} deleted`, C.statusErr);
|
|
828
|
-
refreshAll();
|
|
829
|
-
ensureVisible();
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// ── Key handler ─────────────────────────────────────────────────────
|
|
91
|
+
// ── Create TUI ──────────────────────────────────────────────────────
|
|
833
92
|
|
|
834
|
-
|
|
835
|
-
switch (col) {
|
|
836
|
-
case "global": return { dir: GLOBAL_DIR, label: "~/.agents/skills" };
|
|
837
|
-
case "globalClaude": return { dir: GLOBAL_CLAUDE_DIR, label: "~/.claude/skills" };
|
|
838
|
-
case "local": return { dir: ensureLocalDir(), label: localLabel };
|
|
839
|
-
case "localClaude": return { dir: ensureLocalClaudeDir(), label: localClaudeLabel };
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
function colNext(col: ColId): ColId {
|
|
844
|
-
const i = COL_ORDER.indexOf(col);
|
|
845
|
-
return COL_ORDER[(i + 1) % COL_ORDER.length];
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function colPrev(col: ColId): ColId {
|
|
849
|
-
const i = COL_ORDER.indexOf(col);
|
|
850
|
-
return COL_ORDER[(i - 1 + COL_ORDER.length) % COL_ORDER.length];
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
renderer.keyInput.on("keypress", (key: KeyEvent) => {
|
|
854
|
-
// ── Delete confirmation mode ──
|
|
855
|
-
if (pendingDelete !== null) {
|
|
856
|
-
if (key.name === "y") {
|
|
857
|
-
deleteSkill(pendingDelete);
|
|
858
|
-
} else {
|
|
859
|
-
cancelPendingDelete();
|
|
860
|
-
setStatus("delete cancelled", C.fgDim);
|
|
861
|
-
}
|
|
862
|
-
return;
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
const prevIdx = currentSkillIndex();
|
|
866
|
-
|
|
867
|
-
// ── Escape ──
|
|
868
|
-
if (key.name === "escape") {
|
|
869
|
-
if (focusArea === "search" && searchQuery) {
|
|
870
|
-
searchQuery = "";
|
|
871
|
-
applyFilter();
|
|
872
|
-
refreshAll();
|
|
873
|
-
ensureVisible();
|
|
874
|
-
} else if (focusArea === "search") {
|
|
875
|
-
focusArea = "grid";
|
|
876
|
-
refreshAll();
|
|
877
|
-
ensureVisible();
|
|
878
|
-
} else {
|
|
879
|
-
renderer.destroy();
|
|
880
|
-
process.exit(0);
|
|
881
|
-
}
|
|
882
|
-
return;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
// ── Search-focused input ──
|
|
886
|
-
if (focusArea === "search") {
|
|
887
|
-
if (key.name === "backspace") {
|
|
888
|
-
if (searchQuery) {
|
|
889
|
-
searchQuery = searchQuery.slice(0, -1);
|
|
890
|
-
applyFilter();
|
|
891
|
-
cursor = 0;
|
|
892
|
-
refreshAll();
|
|
893
|
-
ensureVisible();
|
|
894
|
-
}
|
|
895
|
-
return;
|
|
896
|
-
}
|
|
897
|
-
if (key.name === "down" || key.name === "return") {
|
|
898
|
-
focusArea = "grid";
|
|
899
|
-
refreshAll();
|
|
900
|
-
ensureVisible();
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
// Printable character → append to search
|
|
904
|
-
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
905
|
-
searchQuery += key.sequence;
|
|
906
|
-
applyFilter();
|
|
907
|
-
cursor = 0;
|
|
908
|
-
refreshAll();
|
|
909
|
-
ensureVisible();
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
// ── Grid-focused input ──
|
|
916
|
-
|
|
917
|
-
// ── Backspace in grid: do nothing ──
|
|
918
|
-
if (key.name === "backspace") return;
|
|
919
|
-
|
|
920
|
-
if (key.ctrl) return;
|
|
93
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: true });
|
|
921
94
|
|
|
922
|
-
|
|
923
|
-
|
|
95
|
+
const tui = createTui(renderer, {
|
|
96
|
+
allSkills: getCatalogSkills(),
|
|
97
|
+
globalInstalled,
|
|
98
|
+
localInstalled,
|
|
99
|
+
catalogPath: CATALOG,
|
|
100
|
+
|
|
101
|
+
async onToggle(col: ColId, name: string, enable: boolean) {
|
|
102
|
+
const isGlobal = col === "global";
|
|
103
|
+
const args = enable
|
|
104
|
+
? buildAddArgs(name, isGlobal)
|
|
105
|
+
: ["remove", name, "-y", ...(isGlobal ? ["-g"] : [])];
|
|
106
|
+
const proc = Bun.spawn(["npx", "-y", "skills", ...args], {
|
|
107
|
+
stdout: "pipe",
|
|
108
|
+
stderr: "pipe",
|
|
109
|
+
});
|
|
110
|
+
const code = await proc.exited;
|
|
111
|
+
return code === 0;
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async onDelete(name: string) {
|
|
115
|
+
const procs = [];
|
|
116
|
+
if (globalInstalled.has(name)) {
|
|
117
|
+
procs.push(
|
|
118
|
+
Bun.spawn(["npx", "-y", "skills", "remove", name, "-y", "-g"], {
|
|
119
|
+
stdout: "pipe", stderr: "pipe",
|
|
120
|
+
})
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (localInstalled.has(name)) {
|
|
124
|
+
procs.push(
|
|
125
|
+
Bun.spawn(["npx", "-y", "skills", "remove", name, "-y"], {
|
|
126
|
+
stdout: "pipe", stderr: "pipe",
|
|
127
|
+
})
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
for (const p of procs) await p.exited;
|
|
131
|
+
rmSync(join(CATALOG, name), { recursive: true, force: true });
|
|
132
|
+
removeFromLock(name);
|
|
133
|
+
},
|
|
134
|
+
|
|
135
|
+
async onEdit(name: string) {
|
|
136
|
+
const skillPath = join(CATALOG, name);
|
|
924
137
|
renderer.destroy();
|
|
138
|
+
const editor = process.env.EDITOR || "nvim";
|
|
139
|
+
const proc = Bun.spawn([editor, skillPath], {
|
|
140
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
141
|
+
});
|
|
142
|
+
await proc.exited;
|
|
143
|
+
const self = Bun.spawn(["bun", ...process.argv.slice(1)], {
|
|
144
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
145
|
+
});
|
|
146
|
+
await self.exited;
|
|
925
147
|
process.exit(0);
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
if (key.sequence === "/") {
|
|
929
|
-
focusArea = "search";
|
|
930
|
-
refreshAll();
|
|
931
|
-
ensureVisible();
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
|
|
935
|
-
if (key.sequence === "a") {
|
|
936
|
-
toggleAllColumn(cursorCol);
|
|
937
|
-
for (const i of filteredIndices) updateRow(i);
|
|
938
|
-
ensureVisible();
|
|
939
|
-
return;
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
if (key.sequence === "e") {
|
|
943
|
-
const idx = currentSkillIndex();
|
|
944
|
-
if (idx !== null) editSkill(idx);
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
148
|
+
},
|
|
947
149
|
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
}
|
|
954
|
-
return;
|
|
955
|
-
}
|
|
956
|
-
|
|
957
|
-
// ── Navigation & actions ──
|
|
958
|
-
switch (key.name) {
|
|
959
|
-
case "down":
|
|
960
|
-
if (cursor < filteredIndices.length - 1) cursor++;
|
|
961
|
-
break;
|
|
962
|
-
case "up":
|
|
963
|
-
if (cursor > 0) {
|
|
964
|
-
cursor--;
|
|
965
|
-
} else {
|
|
966
|
-
// At top of grid → move to search
|
|
967
|
-
focusArea = "search";
|
|
968
|
-
refreshAll();
|
|
969
|
-
return;
|
|
970
|
-
}
|
|
971
|
-
break;
|
|
972
|
-
case "left":
|
|
973
|
-
cursorCol = colPrev(cursorCol);
|
|
974
|
-
break;
|
|
975
|
-
case "right":
|
|
976
|
-
cursorCol = colNext(cursorCol);
|
|
977
|
-
break;
|
|
978
|
-
case "tab":
|
|
979
|
-
cursorCol = colNext(cursorCol);
|
|
980
|
-
break;
|
|
981
|
-
case "pagedown":
|
|
982
|
-
cursor = Math.min(filteredIndices.length - 1, cursor + 10);
|
|
983
|
-
break;
|
|
984
|
-
case "pageup":
|
|
985
|
-
cursor = Math.max(0, cursor - 10);
|
|
986
|
-
break;
|
|
987
|
-
case "home":
|
|
988
|
-
cursor = 0;
|
|
989
|
-
break;
|
|
990
|
-
case "end":
|
|
991
|
-
cursor = Math.max(0, filteredIndices.length - 1);
|
|
992
|
-
break;
|
|
993
|
-
case "space":
|
|
994
|
-
case "return": {
|
|
995
|
-
const idx = currentSkillIndex();
|
|
996
|
-
if (idx === null) break;
|
|
997
|
-
const skill = allSkills[idx];
|
|
998
|
-
const { dir, label } = dirForCol(cursorCol);
|
|
999
|
-
const ok = toggle(dir, skill);
|
|
1000
|
-
if (ok) {
|
|
1001
|
-
const linked = isEnabled(dir, skill);
|
|
1002
|
-
setStatus(
|
|
1003
|
-
linked ? `✓ ${skill} → ${label}` : `✗ ${skill} removed from ${label}`,
|
|
1004
|
-
linked ? C.statusOk : C.statusErr
|
|
1005
|
-
);
|
|
1006
|
-
} else {
|
|
1007
|
-
setStatus(`⚠ ${skill}: non-library file exists in ${label}, skipped`, C.warning);
|
|
1008
|
-
}
|
|
1009
|
-
break;
|
|
1010
|
-
}
|
|
1011
|
-
default:
|
|
1012
|
-
return;
|
|
1013
|
-
}
|
|
150
|
+
onQuit() {
|
|
151
|
+
renderer.destroy();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
1014
155
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const ci = currentSkillIndex();
|
|
1019
|
-
if (ci !== null) updateRow(ci);
|
|
1020
|
-
ensureVisible();
|
|
156
|
+
process.stdout.on("resize", () => {
|
|
157
|
+
tui.relayout();
|
|
158
|
+
tui.refreshAll();
|
|
1021
159
|
});
|
|
1022
160
|
|
|
1023
161
|
} catch (err: any) {
|