@dealdeploy/skl 0.4.0 → 1.1.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-tui.test.ts +338 -0
- package/add-tui.ts +269 -0
- package/add.ts +81 -328
- package/index.ts +112 -976
- package/lib.test.ts +470 -40
- package/lib.ts +240 -37
- package/package.json +1 -1
- package/skills-lock.json +10 -0
- package/tui.test.ts +565 -0
- package/tui.ts +612 -0
- package/update.ts +90 -90
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, removeFromLock, catalogDir, buildAddArgs, parseSkillsListOutput } 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");
|
|
@@ -34,6 +16,21 @@ if (arg === "-v" || arg === "--version") {
|
|
|
34
16
|
console.log(`skl ${VERSION}`);
|
|
35
17
|
process.exit(0);
|
|
36
18
|
}
|
|
19
|
+
if (arg === "-h" || arg === "--help" || arg === "help") {
|
|
20
|
+
console.log(`skl ${VERSION} — TUI skill manager for Claude Code agents
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
skl Open the interactive TUI
|
|
24
|
+
skl add <repo> Add skills from a GitHub repo (interactive)
|
|
25
|
+
skl add <repo> --all Add all skills from a repo (non-interactive)
|
|
26
|
+
skl update Update all remote skills
|
|
27
|
+
skl help Show this help message
|
|
28
|
+
|
|
29
|
+
Options:
|
|
30
|
+
-h, --help Show this help message
|
|
31
|
+
-v, --version Show version`);
|
|
32
|
+
process.exit(0);
|
|
33
|
+
}
|
|
37
34
|
|
|
38
35
|
// ── Subcommand routing ───────────────────────────────────────────────
|
|
39
36
|
if (arg === "add") {
|
|
@@ -47,977 +44,116 @@ if (arg === "update") {
|
|
|
47
44
|
|
|
48
45
|
try {
|
|
49
46
|
|
|
50
|
-
// ──
|
|
51
|
-
|
|
52
|
-
const
|
|
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(), "~")]);
|
|
47
|
+
// ── Migration: ~/dotfiles/skills → ~/.skl/catalog ───────────────────
|
|
48
|
+
const OLD_LIBRARY = join(homedir(), "dotfiles/skills");
|
|
49
|
+
const CATALOG = catalogDir();
|
|
248
50
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
51
|
+
if (!existsSync(CATALOG) && existsSync(OLD_LIBRARY)) {
|
|
52
|
+
console.log("Migrating skills from ~/dotfiles/skills to ~/.skl/catalog...");
|
|
53
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
54
|
+
for (const name of readdirSync(OLD_LIBRARY)) {
|
|
55
|
+
const src = join(OLD_LIBRARY, name);
|
|
56
|
+
const dest = join(CATALOG, name);
|
|
252
57
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
try {
|
|
256
|
-
if (lstatSync(full).isSymbolicLink()) {
|
|
257
|
-
localSymlinks.push({ dir, label, name, target: readlinkSync(full) });
|
|
258
|
-
}
|
|
259
|
-
} catch {}
|
|
260
|
-
}
|
|
58
|
+
cpSync(src, dest, { recursive: true });
|
|
59
|
+
console.log(` ${name}`);
|
|
261
60
|
} catch {}
|
|
262
61
|
}
|
|
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
|
-
}
|
|
62
|
+
console.log("Done. You can remove ~/dotfiles/skills when ready.\n");
|
|
327
63
|
}
|
|
328
64
|
|
|
329
|
-
|
|
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"];
|
|
65
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
346
66
|
|
|
347
|
-
// ──
|
|
67
|
+
// ── Load installed state from skills CLI ─────────────────────────────
|
|
348
68
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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]" : "[ ]";
|
|
69
|
+
async function listInstalled(global: boolean): Promise<Set<string>> {
|
|
70
|
+
const args = ["npx", "-y", "skills", "list", "--json"];
|
|
71
|
+
if (global) args.push("-g");
|
|
72
|
+
try {
|
|
73
|
+
const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
|
|
74
|
+
const out = await new Response(proc.stdout).text();
|
|
75
|
+
const code = await proc.exited;
|
|
76
|
+
if (code !== 0) return new Set();
|
|
77
|
+
return new Set(parseSkillsListOutput(out.trim()));
|
|
78
|
+
} catch {}
|
|
79
|
+
return new Set();
|
|
374
80
|
}
|
|
375
81
|
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
82
|
+
process.stdout.write("Loading installed skills...");
|
|
83
|
+
const [globalInstalled, localInstalled] = await Promise.all([
|
|
84
|
+
listInstalled(true),
|
|
85
|
+
listInstalled(false),
|
|
86
|
+
]);
|
|
87
|
+
console.log(" done");
|
|
381
88
|
|
|
382
|
-
// ──
|
|
89
|
+
// ── Create TUI ──────────────────────────────────────────────────────
|
|
383
90
|
|
|
384
91
|
const renderer = await createCliRenderer({ exitOnCtrlC: true });
|
|
385
92
|
|
|
386
|
-
const
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
}
|
|
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++;
|
|
772
|
-
}
|
|
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);
|
|
798
|
-
}
|
|
799
|
-
|
|
800
|
-
// ── Delete skill ────────────────────────────────────────────────
|
|
801
|
-
|
|
802
|
-
function cancelPendingDelete() {
|
|
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 });
|
|
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 ─────────────────────────────────────────────────────
|
|
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 };
|
|
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;
|
|
921
|
-
|
|
922
|
-
// ── Single-key shortcuts ──
|
|
923
|
-
if (key.sequence === "q") {
|
|
93
|
+
const tui = createTui(renderer, {
|
|
94
|
+
allSkills: getCatalogSkills(),
|
|
95
|
+
globalInstalled,
|
|
96
|
+
localInstalled,
|
|
97
|
+
catalogPath: CATALOG,
|
|
98
|
+
|
|
99
|
+
async onToggle(col: ColId, name: string, enable: boolean) {
|
|
100
|
+
const isGlobal = col === "global";
|
|
101
|
+
const args = enable
|
|
102
|
+
? buildAddArgs(CATALOG, name, isGlobal)
|
|
103
|
+
: ["remove", name, "-y", ...(isGlobal ? ["-g"] : [])];
|
|
104
|
+
const proc = Bun.spawn(["npx", "-y", "skills", ...args], {
|
|
105
|
+
stdout: "pipe",
|
|
106
|
+
stderr: "pipe",
|
|
107
|
+
});
|
|
108
|
+
const code = await proc.exited;
|
|
109
|
+
return code === 0;
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async onDelete(name: string) {
|
|
113
|
+
const procs = [];
|
|
114
|
+
if (globalInstalled.has(name)) {
|
|
115
|
+
procs.push(
|
|
116
|
+
Bun.spawn(["npx", "-y", "skills", "remove", name, "-y", "-g"], {
|
|
117
|
+
stdout: "pipe", stderr: "pipe",
|
|
118
|
+
})
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (localInstalled.has(name)) {
|
|
122
|
+
procs.push(
|
|
123
|
+
Bun.spawn(["npx", "-y", "skills", "remove", name, "-y"], {
|
|
124
|
+
stdout: "pipe", stderr: "pipe",
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
for (const p of procs) await p.exited;
|
|
129
|
+
rmSync(join(CATALOG, name), { recursive: true, force: true });
|
|
130
|
+
removeFromLock(name);
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
async onEdit(name: string) {
|
|
134
|
+
const skillPath = join(CATALOG, name);
|
|
924
135
|
renderer.destroy();
|
|
136
|
+
const editor = process.env.EDITOR || "nvim";
|
|
137
|
+
const proc = Bun.spawn([editor, skillPath], {
|
|
138
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
139
|
+
});
|
|
140
|
+
await proc.exited;
|
|
141
|
+
const self = Bun.spawn(["bun", ...process.argv.slice(1)], {
|
|
142
|
+
stdio: ["inherit", "inherit", "inherit"],
|
|
143
|
+
});
|
|
144
|
+
await self.exited;
|
|
925
145
|
process.exit(0);
|
|
926
|
-
}
|
|
146
|
+
},
|
|
927
147
|
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
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
|
-
}
|
|
947
|
-
|
|
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
|
-
}
|
|
148
|
+
onQuit() {
|
|
149
|
+
renderer.destroy();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
1014
153
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
const ci = currentSkillIndex();
|
|
1019
|
-
if (ci !== null) updateRow(ci);
|
|
1020
|
-
ensureVisible();
|
|
154
|
+
process.stdout.on("resize", () => {
|
|
155
|
+
tui.relayout();
|
|
156
|
+
tui.refreshAll();
|
|
1021
157
|
});
|
|
1022
158
|
|
|
1023
159
|
} catch (err: any) {
|