@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.
Files changed (8) hide show
  1. package/add.ts +55 -64
  2. package/index.ts +110 -972
  3. package/lib.test.ts +110 -41
  4. package/lib.ts +125 -38
  5. package/package.json +1 -1
  6. package/tui.test.ts +565 -0
  7. package/tui.ts +612 -0
  8. package/update.ts +102 -87
package/index.ts CHANGED
@@ -1,29 +1,11 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import {
4
- createCliRenderer,
5
- BoxRenderable,
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 { findOrphanSkills, adoptSkills } from "./lib.ts";
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
- // ── Constants & helpers ─────────────────────────────────────────────
32
+ // ── Migration: ~/dotfiles/skills ~/.skl/catalog ───────────────────
33
+ const OLD_LIBRARY = join(homedir(), "dotfiles/skills");
34
+ const CATALOG = catalogDir();
51
35
 
52
- const LIBRARY = join(homedir(), "dotfiles/skills");
53
- const GLOBAL_DIR = join(homedir(), ".agents/skills");
54
- const GLOBAL_CLAUDE_DIR = join(homedir(), ".claude/skills");
55
-
56
- if (!existsSync(LIBRARY)) {
57
- console.error(`skl: library not found at ${LIBRARY}`);
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
- for (const name of readdirSync(dir)) {
254
- const full = join(dir, name);
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
- // ── Adopt local-only skills into library ────────────────────────────
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
- const nameText = new TextRenderable(renderer, {
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
- const gChecked = isLibraryLink(GLOBAL_DIR, skill);
518
- const gBlocked = nonLibraryExists(GLOBAL_DIR, skill);
519
- const globalText = new TextRenderable(renderer, {
520
- id: `global-${i}`,
521
- content: checkboxStr(gChecked, gBlocked),
522
- fg: checkboxColor(gChecked, gBlocked, false),
523
- width: COL_W,
524
- });
525
-
526
- const gcChecked = isLibraryLink(GLOBAL_CLAUDE_DIR, skill);
527
- const gcBlocked = nonLibraryExists(GLOBAL_CLAUDE_DIR, skill);
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
- // ── Edit skill ──────────────────────────────────────────────────
782
-
783
- async function editSkill(idx: number) {
784
- const skill = allSkills[idx];
785
- const skillPath = join(LIBRARY, skill);
786
- renderer.destroy();
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
- // ── Delete skill ────────────────────────────────────────────────
77
+ // ── Build add/remove args ────────────────────────────────────────────
801
78
 
802
- function cancelPendingDelete() {
803
- if (pendingDelete !== null) {
804
- pendingDelete = null;
805
- statusLine.content = "";
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
- function deleteSkill(idx: number) {
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
- function dirForCol(col: ColId): { dir: string; label: string } {
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
- // ── Single-key shortcuts ──
923
- if (key.sequence === "q") {
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
- if (key.sequence === "d") {
949
- const idx = currentSkillIndex();
950
- if (idx !== null) {
951
- pendingDelete = idx;
952
- setStatus(`delete ${allSkills[idx]}? (y to confirm)`, C.warning);
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
- if (prevIdx !== null && prevIdx !== currentSkillIndex()) {
1016
- updateRow(prevIdx);
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) {