@dealdeploy/skl 0.1.5 → 0.1.7

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 (3) hide show
  1. package/bun.lock +1 -0
  2. package/index.ts +317 -104
  3. package/package.json +2 -1
package/bun.lock CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "lockfileVersion": 1,
3
+ "configVersion": 0,
3
4
  "workspaces": {
4
5
  "": {
5
6
  "name": "skills-manager",
package/index.ts CHANGED
@@ -1,41 +1,13 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
- import { readFileSync } from "fs";
4
- import { join as _join, dirname } from "path";
5
- import { fileURLToPath } from "url";
6
-
7
- const _pkgPath = _join(dirname(fileURLToPath(import.meta.url)), "package.json");
8
- const _pkg = JSON.parse(readFileSync(_pkgPath, "utf8"));
9
-
10
- // ── Flags ────────────────────────────────────────────────────────────
11
- const arg = process.argv[2];
12
- if (arg === "-v" || arg === "--version") {
13
- console.log(`skl ${_pkg.version}`);
14
- process.exit(0);
15
- }
16
-
17
- // ── Subcommand routing ───────────────────────────────────────────────
18
- if (arg === "add") {
19
- await import("./add.ts").catch((err) => {
20
- console.error(`skl: ${err.message}`);
21
- process.exit(1);
22
- });
23
- process.exit(0);
24
- }
25
-
26
- // ── Dynamic import to ensure errors are visible ──────────────────────
27
- const {
3
+ import {
28
4
  createCliRenderer,
29
5
  BoxRenderable,
30
6
  TextRenderable,
31
7
  ScrollBoxRenderable,
32
8
  TextAttributes,
33
- } = await import("@opentui/core").catch((err) => {
34
- console.error(`skl: failed to load TUI library: ${err.message}`);
35
- process.exit(1);
36
- });
37
- type KeyEvent = import("@opentui/core").KeyEvent;
38
-
9
+ type KeyEvent,
10
+ } from "@opentui/core";
39
11
  import {
40
12
  readdirSync,
41
13
  lstatSync,
@@ -45,14 +17,36 @@ import {
45
17
  mkdirSync,
46
18
  existsSync,
47
19
  rmSync,
20
+ renameSync,
21
+ cpSync,
22
+ readSync,
48
23
  } from "fs";
49
24
  import { join, resolve } from "path";
50
25
  import { homedir } from "os";
51
26
 
27
+ // @ts-ignore - bun supports JSON imports
28
+ const { version: VERSION } = await import("./package.json");
29
+
30
+ // ── Flags ────────────────────────────────────────────────────────────
31
+ const arg = process.argv[2];
32
+ if (arg === "-v" || arg === "--version") {
33
+ console.log(`skl ${VERSION}`);
34
+ process.exit(0);
35
+ }
36
+
37
+ // ── Subcommand routing ───────────────────────────────────────────────
38
+ if (arg === "add") {
39
+ await import("./add.ts");
40
+ process.exit(0);
41
+ }
42
+
43
+ try {
44
+
52
45
  // ── Constants & helpers ─────────────────────────────────────────────
53
46
 
54
47
  const LIBRARY = join(homedir(), "dotfiles/skills");
55
48
  const GLOBAL_DIR = join(homedir(), ".agents/skills");
49
+ const GLOBAL_CLAUDE_DIR = join(homedir(), ".claude/skills");
56
50
 
57
51
  if (!existsSync(LIBRARY)) {
58
52
  console.error(`skl: library not found at ${LIBRARY}`);
@@ -67,15 +61,17 @@ function ellipsize(text: string, max: number): string {
67
61
  return `${text.slice(0, max - 1)}…`;
68
62
  }
69
63
 
70
- // Safety: ensure GLOBAL_DIR is a real directory, not a symlink to the library
71
- try {
72
- if (lstatSync(GLOBAL_DIR).isSymbolicLink()) {
73
- console.error(`ERROR: ${GLOBAL_DIR} is a symlink — it must be a real directory.`);
74
- console.error("Remove it and create a real directory: rm ~/.agents/skills && mkdir ~/.agents/skills");
75
- process.exit(1);
64
+ // Safety: ensure skill dirs aren't symlinks pointing at the library itself
65
+ for (const [dir, label] of [[GLOBAL_DIR, "~/.agents/skills"], [GLOBAL_CLAUDE_DIR, "~/.claude/skills"]] as const) {
66
+ try {
67
+ if (lstatSync(dir).isSymbolicLink() && resolve(readlinkSync(dir)) === resolve(LIBRARY)) {
68
+ console.error(`ERROR: ${label} is a symlink to the library it must be a real directory.`);
69
+ console.error(`Remove it and create a real directory: rm ${label} && mkdir ${label}`);
70
+ process.exit(1);
71
+ }
72
+ } catch {
73
+ // doesn't exist yet, that's fine — toggle will mkdir
76
74
  }
77
- } catch {
78
- // doesn't exist yet, that's fine — toggle will mkdir
79
75
  }
80
76
 
81
77
  function isLibraryLink(dir: string, name: string): boolean {
@@ -125,23 +121,110 @@ function ensureLocalDir(): string {
125
121
  mkdirSync(dir, { recursive: true });
126
122
  localDir = dir;
127
123
  localLabel = dir.replace(homedir(), "~");
128
- colLocal.content = ellipsize(localLabel, 20);
124
+ colLocal.content = ellipsize(localLabel, COL_W - 1);
125
+ refreshAll();
126
+ return dir;
127
+ }
128
+
129
+ function findLocalClaudeDir(): string | null {
130
+ const dir = join(process.cwd(), ".claude/skills");
131
+ if (existsSync(dir)) return dir;
132
+ return null;
133
+ }
134
+
135
+ function ensureLocalClaudeDir(): string {
136
+ if (localClaudeDir) return localClaudeDir;
137
+ const dir = join(process.cwd(), ".claude/skills");
138
+ mkdirSync(dir, { recursive: true });
139
+ localClaudeDir = dir;
140
+ localClaudeLabel = dir.replace(homedir(), "~");
141
+ colLocalClaude.content = ellipsize(localClaudeLabel, COL_W - 1);
129
142
  refreshAll();
130
143
  return dir;
131
144
  }
132
145
 
146
+ // ── Migration check ──────────────────────────────────────────────────
147
+ {
148
+ const dirsToCheck: [string, string][] = [
149
+ [GLOBAL_DIR, "~/.agents/skills"],
150
+ [GLOBAL_CLAUDE_DIR, "~/.claude/skills"],
151
+ ];
152
+ const ld = findLocalDir();
153
+ if (ld) dirsToCheck.push([ld, ld.replace(homedir(), "~")]);
154
+ const lcd = findLocalClaudeDir();
155
+ if (lcd) dirsToCheck.push([lcd, lcd.replace(homedir(), "~")]);
156
+
157
+ const migrations: { dir: string; label: string; name: string; conflict: boolean }[] = [];
158
+
159
+ for (const [dir, label] of dirsToCheck) {
160
+ try {
161
+ for (const name of readdirSync(dir)) {
162
+ if (!isLibraryLink(dir, name)) {
163
+ migrations.push({ dir, label, name, conflict: existsSync(join(LIBRARY, name)) });
164
+ }
165
+ }
166
+ } catch {
167
+ // dir doesn't exist
168
+ }
169
+ }
170
+
171
+ if (migrations.length > 0) {
172
+ console.log("\nFound skills not managed by library:");
173
+ for (const m of migrations) {
174
+ console.log(` ${m.label}/${m.name}${m.conflict ? " (conflict — already in library)" : ""}`);
175
+ }
176
+ process.stdout.write(`\nMigrate to ${LIBRARY.replace(homedir(), "~")} and replace with symlinks? [y/N] `);
177
+
178
+ const buf = new Uint8Array(100);
179
+ const n = readSync(0, buf);
180
+ const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
181
+
182
+ if (answer === "y" || answer === "yes") {
183
+ let migrated = 0;
184
+ let skipped = 0;
185
+ for (const m of migrations) {
186
+ const src = join(m.dir, m.name);
187
+ const dest = join(LIBRARY, m.name);
188
+ if (m.conflict) {
189
+ console.log(` skip ${m.name} (already exists in library)`);
190
+ skipped++;
191
+ continue;
192
+ }
193
+ try {
194
+ renameSync(src, dest);
195
+ } catch {
196
+ // cross-device fallback
197
+ cpSync(src, dest, { recursive: true });
198
+ rmSync(src, { recursive: true, force: true });
199
+ }
200
+ symlinkSync(dest, src);
201
+ console.log(` migrated ${m.name}`);
202
+ migrated++;
203
+ }
204
+ console.log(`\n${migrated} migrated${skipped ? `, ${skipped} skipped` : ""}\n`);
205
+ } else {
206
+ console.log("Skipping migration.\n");
207
+ }
208
+ }
209
+ }
210
+
133
211
  const allSkills = readdirSync(LIBRARY).sort();
134
212
 
135
213
  // ── State ───────────────────────────────────────────────────────────
136
214
 
215
+ type ColId = "global" | "globalClaude" | "local" | "localClaude";
216
+
137
217
  let cursor = 0; // index into filteredIndices
138
- let cursorCol: "global" | "local" = "global";
218
+ let cursorCol: ColId = "global";
219
+ let focusArea: "search" | "grid" = "search";
139
220
  let localDir = findLocalDir();
221
+ let localClaudeDir = findLocalClaudeDir();
140
222
  let statusTimeout: ReturnType<typeof setTimeout> | null = null;
141
223
  let pendingDelete: number | null = null; // allSkills index awaiting confirmation
142
224
  const deletedSkills = new Set<number>();
143
225
  let searchQuery = "";
144
226
  let filteredIndices: number[] = allSkills.map((_, i) => i);
227
+ const COL_ORDER: ColId[] = ["global", "globalClaude", "local", "localClaude"];
145
228
 
146
229
  // ── Colors ──────────────────────────────────────────────────────────
147
230
 
@@ -190,15 +273,22 @@ const outer = new BoxRenderable(renderer, {
190
273
  backgroundColor: C.bg,
191
274
  });
192
275
 
193
- // Search bar (always visible at top, type-to-filter)
276
+ // Search bar (lives inside scrollBox as the first row)
277
+ const searchRow = new BoxRenderable(renderer, {
278
+ id: "search-row",
279
+ flexDirection: "row",
280
+ height: 1,
281
+ width: "100%",
282
+ paddingLeft: 1,
283
+ backgroundColor: C.rowBg,
284
+ });
194
285
  const searchBar = new TextRenderable(renderer, {
195
286
  id: "search-bar",
196
287
  content: "",
197
288
  fg: C.search,
198
- width: "100%",
199
289
  height: 1,
200
- visible: true,
201
290
  });
291
+ searchRow.add(searchBar);
202
292
 
203
293
  const colHeaderRow = new BoxRenderable(renderer, {
204
294
  id: "col-header-row",
@@ -214,31 +304,49 @@ const colName = new TextRenderable(renderer, {
214
304
  attributes: TextAttributes.BOLD,
215
305
  width: 34,
216
306
  });
307
+ const COL_W = 14;
217
308
  const colGlobal = new TextRenderable(renderer, {
218
309
  id: "col-global",
219
310
  content: "~/.agents",
220
311
  fg: C.fgDim,
221
312
  attributes: TextAttributes.BOLD,
222
- width: 12,
313
+ width: COL_W,
314
+ });
315
+ const colGlobalClaude = new TextRenderable(renderer, {
316
+ id: "col-global-claude",
317
+ content: "~/.claude",
318
+ fg: C.fgDim,
319
+ attributes: TextAttributes.BOLD,
320
+ width: COL_W,
223
321
  });
224
- let localLabel = localDir ? localDir.replace(homedir(), "~") : ".agents (n/a)";
225
- const localLabelHeader = ellipsize(localLabel, 20);
322
+ let localLabel = localDir ? localDir.replace(homedir(), "~") : ".agents/skills";
226
323
  const colLocal = new TextRenderable(renderer, {
227
324
  id: "col-local",
228
- content: localLabelHeader,
325
+ content: ellipsize(localLabel, COL_W - 1),
326
+ fg: C.fgDim,
327
+ attributes: TextAttributes.BOLD,
328
+ width: COL_W,
329
+ });
330
+ let localClaudeLabel = localClaudeDir ? localClaudeDir.replace(homedir(), "~") : ".claude/skills";
331
+ const colLocalClaude = new TextRenderable(renderer, {
332
+ id: "col-local-claude",
333
+ content: ellipsize(localClaudeLabel, COL_W - 1),
229
334
  fg: C.fgDim,
230
335
  attributes: TextAttributes.BOLD,
231
- width: 20,
336
+ width: COL_W,
232
337
  });
233
338
 
234
339
  colHeaderRow.add(colName);
235
340
  colHeaderRow.add(colGlobal);
341
+ colHeaderRow.add(colGlobalClaude);
236
342
  colHeaderRow.add(colLocal);
343
+ colHeaderRow.add(colLocalClaude);
237
344
 
238
345
  const sep = new TextRenderable(renderer, {
239
346
  id: "sep",
240
- content: "─".repeat(60),
347
+ content: "─".repeat(200),
241
348
  fg: C.border,
349
+ width: "100%",
242
350
  height: 1,
243
351
  });
244
352
 
@@ -252,10 +360,14 @@ type RowRefs = {
252
360
  row: BoxRenderable;
253
361
  nameText: TextRenderable;
254
362
  globalText: TextRenderable;
363
+ globalClaudeText: TextRenderable;
255
364
  localText: TextRenderable;
365
+ localClaudeText: TextRenderable;
256
366
  };
257
367
  const rows: RowRefs[] = [];
258
368
 
369
+ scrollBox.add(searchRow);
370
+
259
371
  for (let i = 0; i < allSkills.length; i++) {
260
372
  const skill = allSkills[i];
261
373
 
@@ -281,7 +393,16 @@ for (let i = 0; i < allSkills.length; i++) {
281
393
  id: `global-${i}`,
282
394
  content: checkboxStr(gChecked, gBlocked),
283
395
  fg: checkboxColor(gChecked, gBlocked, false),
284
- width: 10,
396
+ width: COL_W,
397
+ });
398
+
399
+ const gcChecked = isLibraryLink(GLOBAL_CLAUDE_DIR, skill);
400
+ const gcBlocked = nonLibraryExists(GLOBAL_CLAUDE_DIR, skill);
401
+ const globalClaudeText = new TextRenderable(renderer, {
402
+ id: `global-claude-${i}`,
403
+ content: checkboxStr(gcChecked, gcBlocked),
404
+ fg: checkboxColor(gcChecked, gcBlocked, false),
405
+ width: COL_W,
285
406
  });
286
407
 
287
408
  const lChecked = localDir ? isLibraryLink(localDir, skill) : false;
@@ -290,26 +411,38 @@ for (let i = 0; i < allSkills.length; i++) {
290
411
  id: `local-${i}`,
291
412
  content: localDir ? checkboxStr(lChecked, lBlocked) : " -",
292
413
  fg: localDir ? checkboxColor(lChecked, lBlocked, false) : C.fgDim,
293
- width: 10,
414
+ width: COL_W,
415
+ });
416
+
417
+ const lcChecked = localClaudeDir ? isLibraryLink(localClaudeDir, skill) : false;
418
+ const lcBlocked = localClaudeDir ? nonLibraryExists(localClaudeDir, skill) : false;
419
+ const localClaudeText = new TextRenderable(renderer, {
420
+ id: `local-claude-${i}`,
421
+ content: localClaudeDir ? checkboxStr(lcChecked, lcBlocked) : " -",
422
+ fg: localClaudeDir ? checkboxColor(lcChecked, lcBlocked, false) : C.fgDim,
423
+ width: COL_W,
294
424
  });
295
425
 
296
426
  row.add(nameText);
297
427
  row.add(globalText);
428
+ row.add(globalClaudeText);
298
429
  row.add(localText);
430
+ row.add(localClaudeText);
299
431
  scrollBox.add(row);
300
- rows.push({ row, nameText, globalText, localText });
432
+ rows.push({ row, nameText, globalText, globalClaudeText, localText, localClaudeText });
301
433
  }
302
434
 
303
435
  const footerSep = new TextRenderable(renderer, {
304
436
  id: "footer-sep",
305
- content: "─".repeat(60),
437
+ content: "─".repeat(200),
306
438
  fg: C.border,
439
+ width: "100%",
307
440
  height: 1,
308
441
  });
309
442
 
310
443
  const footer = new TextRenderable(renderer, {
311
444
  id: "footer",
312
- content: " type to filter ↑↓ move ←→/tab col enter toggle ^a all ^e edit ^d del esc quit",
445
+ content: " ↑↓ move ←→/tab col enter toggle ^a all ^e edit ^d del ↑search esc quit",
313
446
  fg: C.footer,
314
447
  height: 1,
315
448
  });
@@ -321,7 +454,6 @@ const statusLine = new TextRenderable(renderer, {
321
454
  height: 1,
322
455
  });
323
456
 
324
- outer.add(searchBar);
325
457
  outer.add(colHeaderRow);
326
458
  outer.add(sep);
327
459
  outer.add(scrollBox);
@@ -353,7 +485,15 @@ function applyFilter() {
353
485
  }
354
486
 
355
487
  function updateSearchBar() {
356
- searchBar.content = ` / ${searchQuery}█`;
488
+ if (focusArea === "search") {
489
+ searchBar.content = `▸ ${searchQuery}█`;
490
+ searchBar.fg = C.search;
491
+ searchRow.backgroundColor = C.cursorBg;
492
+ } else {
493
+ searchBar.content = searchQuery ? ` ${searchQuery}` : ` search...`;
494
+ searchBar.fg = C.fgDim;
495
+ searchRow.backgroundColor = C.rowBg;
496
+ }
357
497
  }
358
498
 
359
499
  // ── Update display ──────────────────────────────────────────────────
@@ -366,7 +506,7 @@ function updateRow(i: number) {
366
506
  const skill = allSkills[i];
367
507
  const r = rows[i];
368
508
  const ci = currentSkillIndex();
369
- const isCursor = ci === i;
509
+ const isCursor = ci === i && focusArea === "grid";
370
510
 
371
511
  const visPos = filteredIndices.indexOf(i);
372
512
  const baseBg = visPos % 2 === 0 ? C.rowBg : C.rowAltBg;
@@ -385,6 +525,14 @@ function updateRow(i: number) {
385
525
  r.globalText.bg = gActive ? C.accentBg : undefined;
386
526
  r.globalText.attributes = gActive ? TextAttributes.BOLD : TextAttributes.NONE;
387
527
 
528
+ const gcChecked = isLibraryLink(GLOBAL_CLAUDE_DIR, skill);
529
+ const gcBlocked = nonLibraryExists(GLOBAL_CLAUDE_DIR, skill);
530
+ const gcActive = isCursor && cursorCol === "globalClaude";
531
+ r.globalClaudeText.content = checkboxStr(gcChecked, gcBlocked);
532
+ r.globalClaudeText.fg = checkboxColor(gcChecked, gcBlocked, gcActive);
533
+ r.globalClaudeText.bg = gcActive ? C.accentBg : undefined;
534
+ r.globalClaudeText.attributes = gcActive ? TextAttributes.BOLD : TextAttributes.NONE;
535
+
388
536
  const lChecked = localDir ? isLibraryLink(localDir, skill) : false;
389
537
  const lBlocked = localDir ? nonLibraryExists(localDir, skill) : false;
390
538
  const lActive = isCursor && cursorCol === "local";
@@ -398,6 +546,20 @@ function updateRow(i: number) {
398
546
  r.localText.fg = C.fgDim;
399
547
  r.localText.bg = lActive ? C.accentBg : undefined;
400
548
  }
549
+
550
+ const lcChecked = localClaudeDir ? isLibraryLink(localClaudeDir, skill) : false;
551
+ const lcBlocked = localClaudeDir ? nonLibraryExists(localClaudeDir, skill) : false;
552
+ const lcActive = isCursor && cursorCol === "localClaude";
553
+ if (localClaudeDir) {
554
+ r.localClaudeText.content = checkboxStr(lcChecked, lcBlocked);
555
+ r.localClaudeText.fg = checkboxColor(lcChecked, lcBlocked, lcActive);
556
+ r.localClaudeText.bg = lcActive ? C.accentBg : undefined;
557
+ r.localClaudeText.attributes = lcActive ? TextAttributes.BOLD : TextAttributes.NONE;
558
+ } else {
559
+ r.localClaudeText.content = " -";
560
+ r.localClaudeText.fg = C.fgDim;
561
+ r.localClaudeText.bg = lcActive ? C.accentBg : undefined;
562
+ }
401
563
  }
402
564
 
403
565
  function setStatus(msg: string, color: string) {
@@ -419,13 +581,25 @@ refreshAll();
419
581
  // ── Scrolling helper ────────────────────────────────────────────────
420
582
 
421
583
  function ensureVisible() {
422
- scrollBox.scrollTo(Math.max(0, cursor - 2));
584
+ if (focusArea === "search") {
585
+ scrollBox.scrollTo(0);
586
+ } else {
587
+ // +1 offset because searchRow is child 0 in scrollBox
588
+ scrollBox.scrollTo(Math.max(0, cursor + 1 - 2));
589
+ }
423
590
  }
424
591
 
425
592
  // ── Toggle all in column ────────────────────────────────────────────
426
593
 
427
- function toggleAllColumn(col: "global" | "local") {
428
- const dir = col === "global" ? GLOBAL_DIR : ensureLocalDir();
594
+ function toggleAllColumn(col: ColId) {
595
+ let dir: string;
596
+ let dirLabel: string;
597
+ switch (col) {
598
+ case "global": dir = GLOBAL_DIR; dirLabel = "~/.agents"; break;
599
+ case "globalClaude": dir = GLOBAL_CLAUDE_DIR; dirLabel = "~/.claude"; break;
600
+ case "local": dir = ensureLocalDir(); dirLabel = localLabel; break;
601
+ case "localClaude": dir = ensureLocalClaudeDir(); dirLabel = localClaudeLabel; break;
602
+ }
429
603
 
430
604
  // Determine intent: if majority of visible skills are linked, unlink all; otherwise link all
431
605
  let linkedCount = 0;
@@ -447,7 +621,6 @@ function toggleAllColumn(col: "global" | "local") {
447
621
  }
448
622
  }
449
623
 
450
- const dirLabel = col === "global" ? "~/.agents" : localLabel;
451
624
  const action = shouldLink ? "linked" : "unlinked";
452
625
  let msg = `${action} ${changed} skills in ${dirLabel}`;
453
626
  if (skipped) msg += ` (${skipped} skipped)`;
@@ -487,7 +660,9 @@ function deleteSkill(idx: number) {
487
660
 
488
661
  // Remove symlinks first
489
662
  if (isLibraryLink(GLOBAL_DIR, skill)) unlinkSync(join(GLOBAL_DIR, skill));
663
+ if (isLibraryLink(GLOBAL_CLAUDE_DIR, skill)) unlinkSync(join(GLOBAL_CLAUDE_DIR, skill));
490
664
  if (localDir && isLibraryLink(localDir, skill)) unlinkSync(join(localDir, skill));
665
+ if (localClaudeDir && isLibraryLink(localClaudeDir, skill)) unlinkSync(join(localClaudeDir, skill));
491
666
 
492
667
  // Remove from library
493
668
  rmSync(join(LIBRARY, skill), { recursive: true, force: true });
@@ -505,6 +680,25 @@ function deleteSkill(idx: number) {
505
680
 
506
681
  // ── Key handler ─────────────────────────────────────────────────────
507
682
 
683
+ function dirForCol(col: ColId): { dir: string; label: string } {
684
+ switch (col) {
685
+ case "global": return { dir: GLOBAL_DIR, label: "~/.agents/skills" };
686
+ case "globalClaude": return { dir: GLOBAL_CLAUDE_DIR, label: "~/.claude/skills" };
687
+ case "local": return { dir: ensureLocalDir(), label: localLabel };
688
+ case "localClaude": return { dir: ensureLocalClaudeDir(), label: localClaudeLabel };
689
+ }
690
+ }
691
+
692
+ function colNext(col: ColId): ColId {
693
+ const i = COL_ORDER.indexOf(col);
694
+ return COL_ORDER[(i + 1) % COL_ORDER.length];
695
+ }
696
+
697
+ function colPrev(col: ColId): ColId {
698
+ const i = COL_ORDER.indexOf(col);
699
+ return COL_ORDER[(i - 1 + COL_ORDER.length) % COL_ORDER.length];
700
+ }
701
+
508
702
  renderer.keyInput.on("keypress", (key: KeyEvent) => {
509
703
  // ── Delete confirmation mode ──
510
704
  if (pendingDelete !== null) {
@@ -519,13 +713,17 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
519
713
 
520
714
  const prevIdx = currentSkillIndex();
521
715
 
522
- // ── Escape: clear search or quit ──
716
+ // ── Escape ──
523
717
  if (key.name === "escape") {
524
- if (searchQuery) {
718
+ if (focusArea === "search" && searchQuery) {
525
719
  searchQuery = "";
526
720
  applyFilter();
527
721
  refreshAll();
528
722
  ensureVisible();
723
+ } else if (focusArea === "search") {
724
+ focusArea = "grid";
725
+ refreshAll();
726
+ ensureVisible();
529
727
  } else {
530
728
  renderer.destroy();
531
729
  process.exit(0);
@@ -533,18 +731,41 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
533
731
  return;
534
732
  }
535
733
 
536
- // ── Backspace: delete from search ──
537
- if (key.name === "backspace") {
538
- if (searchQuery) {
539
- searchQuery = searchQuery.slice(0, -1);
734
+ // ── Search-focused input ──
735
+ if (focusArea === "search") {
736
+ if (key.name === "backspace") {
737
+ if (searchQuery) {
738
+ searchQuery = searchQuery.slice(0, -1);
739
+ applyFilter();
740
+ cursor = 0;
741
+ refreshAll();
742
+ ensureVisible();
743
+ }
744
+ return;
745
+ }
746
+ if (key.name === "down" || key.name === "return") {
747
+ focusArea = "grid";
748
+ refreshAll();
749
+ ensureVisible();
750
+ return;
751
+ }
752
+ // Printable character → append to search
753
+ if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
754
+ searchQuery += key.sequence;
540
755
  applyFilter();
541
756
  cursor = 0;
542
757
  refreshAll();
543
758
  ensureVisible();
759
+ return;
544
760
  }
545
761
  return;
546
762
  }
547
763
 
764
+ // ── Grid-focused input ──
765
+
766
+ // ── Backspace in grid: do nothing ──
767
+ if (key.name === "backspace") return;
768
+
548
769
  // ── Ctrl combos ──
549
770
  if (key.ctrl) {
550
771
  switch (key.name) {
@@ -570,22 +791,29 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
570
791
  return;
571
792
  }
572
793
 
573
- // ── Navigation & actions (arrow keys, enter, tab) ──
794
+ // ── Navigation & actions ──
574
795
  switch (key.name) {
575
796
  case "down":
576
797
  if (cursor < filteredIndices.length - 1) cursor++;
577
798
  break;
578
799
  case "up":
579
- if (cursor > 0) cursor--;
800
+ if (cursor > 0) {
801
+ cursor--;
802
+ } else {
803
+ // At top of grid → move to search
804
+ focusArea = "search";
805
+ refreshAll();
806
+ return;
807
+ }
580
808
  break;
581
809
  case "left":
582
- cursorCol = "global";
810
+ cursorCol = colPrev(cursorCol);
583
811
  break;
584
812
  case "right":
585
- cursorCol = "local";
813
+ cursorCol = colNext(cursorCol);
586
814
  break;
587
815
  case "tab":
588
- cursorCol = cursorCol === "global" ? "local" : "global";
816
+ cursorCol = colNext(cursorCol);
589
817
  break;
590
818
  case "pagedown":
591
819
  cursor = Math.min(filteredIndices.length - 1, cursor + 10);
@@ -599,46 +827,25 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
599
827
  case "end":
600
828
  cursor = Math.max(0, filteredIndices.length - 1);
601
829
  break;
830
+ case "space":
602
831
  case "return": {
603
832
  const idx = currentSkillIndex();
604
833
  if (idx === null) break;
605
834
  const skill = allSkills[idx];
606
- if (cursorCol === "global") {
607
- const ok = toggle(GLOBAL_DIR, skill);
608
- if (ok) {
609
- const linked = isLibraryLink(GLOBAL_DIR, skill);
610
- setStatus(
611
- linked ? `✓ ${skill} → ~/.agents/skills` : `✗ ${skill} removed from ~/.agents/skills`,
612
- linked ? C.statusOk : C.statusErr
613
- );
614
- } else {
615
- setStatus(`⚠ ${skill}: non-library file exists in ~/.agents/skills, skipped`, C.warning);
616
- }
835
+ const { dir, label } = dirForCol(cursorCol);
836
+ const ok = toggle(dir, skill);
837
+ if (ok) {
838
+ const linked = isLibraryLink(dir, skill);
839
+ setStatus(
840
+ linked ? `✓ ${skill} → ${label}` : `✗ ${skill} removed from ${label}`,
841
+ linked ? C.statusOk : C.statusErr
842
+ );
617
843
  } else {
618
- const dir = ensureLocalDir();
619
- const ok = toggle(dir, skill);
620
- if (ok) {
621
- const linked = isLibraryLink(localDir, skill);
622
- setStatus(
623
- linked ? `✓ ${skill} → ${localLabel}` : `✗ ${skill} removed from ${localLabel}`,
624
- linked ? C.statusOk : C.statusErr
625
- );
626
- } else {
627
- setStatus(`⚠ ${skill}: non-library file exists in ${localLabel}, skipped`, C.warning);
628
- }
844
+ setStatus(`⚠ ${skill}: non-library file exists in ${label}, skipped`, C.warning);
629
845
  }
630
846
  break;
631
847
  }
632
848
  default:
633
- // ── Printable character → search input ──
634
- if (key.sequence && key.sequence.length === 1 && !key.meta) {
635
- searchQuery += key.sequence;
636
- applyFilter();
637
- cursor = 0;
638
- refreshAll();
639
- ensureVisible();
640
- return;
641
- }
642
849
  return;
643
850
  }
644
851
 
@@ -649,3 +856,9 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
649
856
  if (ci !== null) updateRow(ci);
650
857
  ensureVisible();
651
858
  });
859
+
860
+ } catch (err: any) {
861
+ console.error(`skl: fatal: ${err?.message ?? err}`);
862
+ if (err?.stack) console.error(err.stack);
863
+ process.exit(1);
864
+ }
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dealdeploy/skl",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "TUI skill manager for Claude Code agents",
5
5
  "module": "index.ts",
6
6
  "bin": {
7
7
  "skl": "index.ts"
8
8
  },
9
9
  "scripts": {
10
+ "dev": "bun run index.ts",
10
11
  "release": "npm version patch && git push && git push --tags",
11
12
  "release:minor": "npm version minor && git push && git push --tags",
12
13
  "release:major": "npm version major && git push && git push --tags"