@dealdeploy/skl 0.1.8 → 0.3.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/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
@@ -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 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
  });
@@ -908,6 +942,12 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
908
942
  return;
909
943
  }
910
944
 
945
+ // ── "q" to quit ──
946
+ if (key.sequence === "q") {
947
+ renderer.destroy();
948
+ process.exit(0);
949
+ }
950
+
911
951
  // ── "/" to focus search (vim-style) ──
912
952
  if (key.sequence === "/") {
913
953
  focusArea = "search";
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.1.8",
3
+ "version": "0.3.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
  }
@@ -1,11 +0,0 @@
1
- {
2
- "permissions": {
3
- "allow": [
4
- "Bash(git add:*)",
5
- "Bash(comm -23 /tmp/local_claude.txt /tmp/global_claude.txt)",
6
- "Bash(comm -13 /tmp/local_claude.txt /tmp/global_claude.txt)",
7
- "Bash(comm -12 /tmp/local_claude.txt /tmp/global_claude.txt)",
8
- "Bash(/usr/bin/ls:*)"
9
- ]
10
- }
11
- }