@dealdeploy/skl 0.3.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 -974
- 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,979 +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
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
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
|
-
});
|
|
509
|
-
|
|
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
|
-
});
|
|
516
|
-
|
|
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
|
-
}
|
|
47
|
+
console.log("Done. You can remove ~/dotfiles/skills when ready.\n");
|
|
741
48
|
}
|
|
742
49
|
|
|
743
|
-
|
|
50
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
744
51
|
|
|
745
|
-
|
|
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
|
-
}
|
|
52
|
+
// ── Load installed state from skills CLI ─────────────────────────────
|
|
754
53
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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);
|
|
779
|
-
}
|
|
780
|
-
|
|
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);
|
|
66
|
+
} catch {}
|
|
67
|
+
return new Set();
|
|
798
68
|
}
|
|
799
69
|
|
|
800
|
-
|
|
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");
|
|
801
76
|
|
|
802
|
-
|
|
803
|
-
if (pendingDelete !== null) {
|
|
804
|
-
pendingDelete = null;
|
|
805
|
-
statusLine.content = "";
|
|
806
|
-
}
|
|
807
|
-
}
|
|
808
|
-
|
|
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 });
|
|
77
|
+
// ── Build add/remove args ────────────────────────────────────────────
|
|
817
78
|
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
pendingDelete = null;
|
|
825
|
-
|
|
826
|
-
applyFilter();
|
|
827
|
-
setStatus(`🗑 ${skill} deleted`, C.statusErr);
|
|
828
|
-
refreshAll();
|
|
829
|
-
ensureVisible();
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
// ── Key handler ─────────────────────────────────────────────────────
|
|
833
|
-
|
|
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 };
|
|
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;
|
|
840
85
|
}
|
|
86
|
+
const args = ["add", join(CATALOG, name), "-y"];
|
|
87
|
+
if (global) args.push("-g");
|
|
88
|
+
return args;
|
|
841
89
|
}
|
|
842
90
|
|
|
843
|
-
|
|
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;
|
|
91
|
+
// ── Create TUI ──────────────────────────────────────────────────────
|
|
919
92
|
|
|
920
|
-
|
|
921
|
-
if (key.ctrl) {
|
|
922
|
-
switch (key.name) {
|
|
923
|
-
case "a":
|
|
924
|
-
toggleAllColumn(cursorCol);
|
|
925
|
-
for (const i of filteredIndices) updateRow(i);
|
|
926
|
-
ensureVisible();
|
|
927
|
-
return;
|
|
928
|
-
case "e": {
|
|
929
|
-
const idx = currentSkillIndex();
|
|
930
|
-
if (idx !== null) editSkill(idx);
|
|
931
|
-
return;
|
|
932
|
-
}
|
|
933
|
-
case "d": {
|
|
934
|
-
const idx = currentSkillIndex();
|
|
935
|
-
if (idx !== null) {
|
|
936
|
-
pendingDelete = idx;
|
|
937
|
-
setStatus(`delete ${allSkills[idx]}? (y to confirm)`, C.warning);
|
|
938
|
-
}
|
|
939
|
-
return;
|
|
940
|
-
}
|
|
941
|
-
}
|
|
942
|
-
return;
|
|
943
|
-
}
|
|
93
|
+
const renderer = await createCliRenderer({ exitOnCtrlC: true });
|
|
944
94
|
|
|
945
|
-
|
|
946
|
-
|
|
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);
|
|
947
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;
|
|
948
147
|
process.exit(0);
|
|
949
|
-
}
|
|
148
|
+
},
|
|
950
149
|
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
return;
|
|
957
|
-
}
|
|
958
|
-
|
|
959
|
-
// ── Navigation & actions ──
|
|
960
|
-
switch (key.name) {
|
|
961
|
-
case "down":
|
|
962
|
-
if (cursor < filteredIndices.length - 1) cursor++;
|
|
963
|
-
break;
|
|
964
|
-
case "up":
|
|
965
|
-
if (cursor > 0) {
|
|
966
|
-
cursor--;
|
|
967
|
-
} else {
|
|
968
|
-
// At top of grid → move to search
|
|
969
|
-
focusArea = "search";
|
|
970
|
-
refreshAll();
|
|
971
|
-
return;
|
|
972
|
-
}
|
|
973
|
-
break;
|
|
974
|
-
case "left":
|
|
975
|
-
cursorCol = colPrev(cursorCol);
|
|
976
|
-
break;
|
|
977
|
-
case "right":
|
|
978
|
-
cursorCol = colNext(cursorCol);
|
|
979
|
-
break;
|
|
980
|
-
case "tab":
|
|
981
|
-
cursorCol = colNext(cursorCol);
|
|
982
|
-
break;
|
|
983
|
-
case "pagedown":
|
|
984
|
-
cursor = Math.min(filteredIndices.length - 1, cursor + 10);
|
|
985
|
-
break;
|
|
986
|
-
case "pageup":
|
|
987
|
-
cursor = Math.max(0, cursor - 10);
|
|
988
|
-
break;
|
|
989
|
-
case "home":
|
|
990
|
-
cursor = 0;
|
|
991
|
-
break;
|
|
992
|
-
case "end":
|
|
993
|
-
cursor = Math.max(0, filteredIndices.length - 1);
|
|
994
|
-
break;
|
|
995
|
-
case "space":
|
|
996
|
-
case "return": {
|
|
997
|
-
const idx = currentSkillIndex();
|
|
998
|
-
if (idx === null) break;
|
|
999
|
-
const skill = allSkills[idx];
|
|
1000
|
-
const { dir, label } = dirForCol(cursorCol);
|
|
1001
|
-
const ok = toggle(dir, skill);
|
|
1002
|
-
if (ok) {
|
|
1003
|
-
const linked = isEnabled(dir, skill);
|
|
1004
|
-
setStatus(
|
|
1005
|
-
linked ? `✓ ${skill} → ${label}` : `✗ ${skill} removed from ${label}`,
|
|
1006
|
-
linked ? C.statusOk : C.statusErr
|
|
1007
|
-
);
|
|
1008
|
-
} else {
|
|
1009
|
-
setStatus(`⚠ ${skill}: non-library file exists in ${label}, skipped`, C.warning);
|
|
1010
|
-
}
|
|
1011
|
-
break;
|
|
1012
|
-
}
|
|
1013
|
-
default:
|
|
1014
|
-
return;
|
|
1015
|
-
}
|
|
150
|
+
onQuit() {
|
|
151
|
+
renderer.destroy();
|
|
152
|
+
process.exit(0);
|
|
153
|
+
},
|
|
154
|
+
});
|
|
1016
155
|
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
const ci = currentSkillIndex();
|
|
1021
|
-
if (ci !== null) updateRow(ci);
|
|
1022
|
-
ensureVisible();
|
|
156
|
+
process.stdout.on("resize", () => {
|
|
157
|
+
tui.relayout();
|
|
158
|
+
tui.refreshAll();
|
|
1023
159
|
});
|
|
1024
160
|
|
|
1025
161
|
} catch (err: any) {
|