@dealdeploy/skl 0.2.0 → 0.4.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/index.ts CHANGED
@@ -23,6 +23,7 @@ import {
23
23
  } from "fs";
24
24
  import { join, resolve } from "path";
25
25
  import { homedir } from "os";
26
+ import { findOrphanSkills, adoptSkills } from "./lib.ts";
26
27
 
27
28
  // @ts-ignore - bun supports JSON imports
28
29
  const { version: VERSION } = await import("./package.json");
@@ -292,6 +293,39 @@ function ensureLocalClaudeDir(): string {
292
293
  }
293
294
  }
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
+
295
329
  const allSkills = readdirSync(LIBRARY).sort();
296
330
 
297
331
  // ── State ───────────────────────────────────────────────────────────
@@ -535,7 +569,7 @@ const footerSep = new TextRenderable(renderer, {
535
569
 
536
570
  const footer = new TextRenderable(renderer, {
537
571
  id: "footer",
538
- content: " ↑↓ move ←→/tab col enter toggle ^a all ^e edit ^d del / search q/esc quit",
572
+ content: " ↑↓ move ←→/tab col enter toggle a all e edit d del / search q/esc quit",
539
573
  fg: C.footer,
540
574
  height: 1,
541
575
  });
@@ -883,38 +917,14 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
883
917
  // ── Backspace in grid: do nothing ──
884
918
  if (key.name === "backspace") return;
885
919
 
886
- // ── Ctrl combos ──
887
- if (key.ctrl) {
888
- switch (key.name) {
889
- case "a":
890
- toggleAllColumn(cursorCol);
891
- for (const i of filteredIndices) updateRow(i);
892
- ensureVisible();
893
- return;
894
- case "e": {
895
- const idx = currentSkillIndex();
896
- if (idx !== null) editSkill(idx);
897
- return;
898
- }
899
- case "d": {
900
- const idx = currentSkillIndex();
901
- if (idx !== null) {
902
- pendingDelete = idx;
903
- setStatus(`delete ${allSkills[idx]}? (y to confirm)`, C.warning);
904
- }
905
- return;
906
- }
907
- }
908
- return;
909
- }
920
+ if (key.ctrl) return;
910
921
 
911
- // ── "q" to quit ──
922
+ // ── Single-key shortcuts ──
912
923
  if (key.sequence === "q") {
913
924
  renderer.destroy();
914
925
  process.exit(0);
915
926
  }
916
927
 
917
- // ── "/" to focus search (vim-style) ──
918
928
  if (key.sequence === "/") {
919
929
  focusArea = "search";
920
930
  refreshAll();
@@ -922,6 +932,28 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
922
932
  return;
923
933
  }
924
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
+
925
957
  // ── Navigation & actions ──
926
958
  switch (key.name) {
927
959
  case "down":
package/lib.test.ts ADDED
@@ -0,0 +1,70 @@
1
+ import { test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdirSync, rmSync, existsSync, writeFileSync, symlinkSync } from "fs";
3
+ import { join } from "path";
4
+ import { mkdtempSync } from "fs";
5
+ import { tmpdir } from "os";
6
+
7
+ import { findOrphanSkills, adoptSkills } from "./lib.ts";
8
+
9
+ let tmp: string;
10
+ let library: string;
11
+ let localDir: string;
12
+
13
+ beforeEach(() => {
14
+ tmp = mkdtempSync(join(tmpdir(), "skl-test-"));
15
+ library = join(tmp, "library");
16
+ localDir = join(tmp, "local");
17
+ mkdirSync(library);
18
+ mkdirSync(localDir);
19
+ });
20
+
21
+ afterEach(() => {
22
+ rmSync(tmp, { recursive: true, force: true });
23
+ });
24
+
25
+ test("detects a local skill not in the library", () => {
26
+ mkdirSync(join(localDir, "my-skill"));
27
+ mkdirSync(join(library, "other-skill"));
28
+
29
+ const orphans = findOrphanSkills([localDir], library);
30
+
31
+ expect(orphans).toEqual([{ dir: localDir, name: "my-skill" }]);
32
+ });
33
+
34
+ test("deduplicates when same skill exists in multiple local dirs", () => {
35
+ const localDir2 = join(tmp, "local2");
36
+ mkdirSync(localDir2);
37
+ mkdirSync(join(localDir, "shared-skill"));
38
+ mkdirSync(join(localDir2, "shared-skill"));
39
+
40
+ const orphans = findOrphanSkills([localDir, localDir2], library);
41
+
42
+ expect(orphans).toHaveLength(1);
43
+ expect(orphans[0]!.name).toBe("shared-skill");
44
+ });
45
+
46
+ test("adoptSkills copies orphan skills into the library", () => {
47
+ // Create a local skill with a file inside
48
+ mkdirSync(join(localDir, "cool-skill"));
49
+ writeFileSync(join(localDir, "cool-skill", "prompt.md"), "# Cool Skill\nDo stuff");
50
+
51
+ const orphans = [{ dir: localDir, name: "cool-skill" }];
52
+ const results = adoptSkills(orphans, library);
53
+
54
+ expect(results).toEqual([{ name: "cool-skill", copied: true }]);
55
+ expect(existsSync(join(library, "cool-skill", "prompt.md"))).toBe(true);
56
+ });
57
+
58
+ test("ignores symlinks and plain files in local dirs", () => {
59
+ // A regular file (not a directory)
60
+ writeFileSync(join(localDir, "not-a-skill"), "just a file");
61
+ // A symlink
62
+ mkdirSync(join(tmp, "symlink-target"));
63
+ symlinkSync(join(tmp, "symlink-target"), join(localDir, "linked-skill"));
64
+ // A real skill directory (should be detected)
65
+ mkdirSync(join(localDir, "real-skill"));
66
+
67
+ const orphans = findOrphanSkills([localDir], library);
68
+
69
+ expect(orphans).toEqual([{ dir: localDir, name: "real-skill" }]);
70
+ });
package/lib.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { readdirSync, lstatSync, existsSync, cpSync } from "fs";
2
+ import { join } from "path";
3
+
4
+ export function findOrphanSkills(
5
+ localDirs: string[],
6
+ library: string,
7
+ ): { dir: string; name: string }[] {
8
+ const orphans: { dir: string; name: string }[] = [];
9
+ const seen = new Set<string>();
10
+
11
+ for (const dir of localDirs) {
12
+ try {
13
+ for (const name of readdirSync(dir)) {
14
+ if (seen.has(name)) continue;
15
+ const full = join(dir, name);
16
+ try {
17
+ const stat = lstatSync(full);
18
+ if (stat.isSymbolicLink()) continue;
19
+ if (!stat.isDirectory()) continue;
20
+ if (!existsSync(join(library, name))) {
21
+ seen.add(name);
22
+ orphans.push({ dir, name });
23
+ }
24
+ } catch {}
25
+ }
26
+ } catch {}
27
+ }
28
+
29
+ return orphans;
30
+ }
31
+
32
+ export function adoptSkills(
33
+ orphans: { dir: string; name: string }[],
34
+ library: string,
35
+ ): { name: string; copied: boolean }[] {
36
+ const results: { name: string; copied: boolean }[] = [];
37
+
38
+ for (const { dir, name } of orphans) {
39
+ const src = join(dir, name);
40
+ const dest = join(library, name);
41
+ try {
42
+ cpSync(src, dest, { recursive: true });
43
+ results.push({ name, copied: true });
44
+ } catch {
45
+ results.push({ name, copied: false });
46
+ }
47
+ }
48
+
49
+ return results;
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dealdeploy/skl",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "TUI skill manager for Claude Code agents",
5
5
  "module": "index.ts",
6
6
  "bin": {
package/update.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  import { readdirSync, existsSync, cpSync, rmSync, lstatSync, readlinkSync, readSync, unlinkSync } from "fs";
4
4
  import { join, resolve } from "path";
5
5
  import { homedir } from "os";
6
+ import { findOrphanSkills, adoptSkills } from "./lib.ts";
6
7
 
7
8
  const LIBRARY = join(homedir(), "dotfiles/skills");
8
9
 
@@ -27,16 +28,11 @@ type Entry = { dir: string; label: string; name: string; kind: "symlink" | "copy
27
28
 
28
29
  const symlinks: Entry[] = [];
29
30
  const copies: Entry[] = [];
30
- let skipped = 0;
31
-
32
31
  for (const [dir, label] of localDirs) {
33
32
  for (const name of readdirSync(dir)) {
34
33
  const localPath = join(dir, name);
35
34
  const libPath = join(LIBRARY, name);
36
- if (!existsSync(libPath)) {
37
- skipped++;
38
- continue;
39
- }
35
+ if (!existsSync(libPath)) continue;
40
36
  const isSym = lstatSync(localPath).isSymbolicLink();
41
37
  const entry: Entry = { dir, label, name, kind: isSym ? "symlink" : "copy" };
42
38
  if (isSym) symlinks.push(entry);
@@ -44,6 +40,8 @@ for (const [dir, label] of localDirs) {
44
40
  }
45
41
  }
46
42
 
43
+ const notInLibrary = findOrphanSkills(localDirs.map(([dir]) => dir), LIBRARY);
44
+
47
45
  // Handle symlinks → copies (requires confirmation)
48
46
  if (symlinks.length > 0) {
49
47
  console.log("\nSymlinks to convert to local copies:");
@@ -79,9 +77,29 @@ if (copies.length > 0) {
79
77
  }
80
78
  }
81
79
 
82
- if (symlinks.length === 0 && copies.length === 0) {
80
+ if (symlinks.length === 0 && copies.length === 0 && notInLibrary.length === 0) {
83
81
  console.log("Nothing to update.");
84
82
  }
85
- if (skipped > 0) {
86
- console.log(`${skipped} skipped (not in library)`);
83
+
84
+ // Offer to copy local-only skills into library
85
+ if (notInLibrary.length > 0) {
86
+ console.log("\nFound skills in this repo not in your library:");
87
+ for (const o of notInLibrary) {
88
+ console.log(` ${o.dir.replace(homedir(), "~")}/${o.name}`);
89
+ }
90
+ process.stdout.write(`\nCopy to ${LIBRARY.replace(homedir(), "~")}? [y/N] `);
91
+
92
+ const buf = new Uint8Array(100);
93
+ const n = readSync(0, buf);
94
+ const answer = new TextDecoder().decode(buf.subarray(0, n)).trim().toLowerCase();
95
+
96
+ if (answer === "y" || answer === "yes") {
97
+ const results = adoptSkills(notInLibrary, LIBRARY);
98
+ for (const r of results) {
99
+ console.log(` copied ${r.name}`);
100
+ }
101
+ console.log(`\n${results.filter(r => r.copied).length} added to library`);
102
+ } else {
103
+ console.log("Skipping.");
104
+ }
87
105
  }