@dealdeploy/skl 0.2.0 → 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/index.ts +34 -0
- package/lib.test.ts +70 -0
- package/lib.ts +50 -0
- package/package.json +1 -1
- package/update.ts +27 -9
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 ───────────────────────────────────────────────────────────
|
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
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
|
-
|
|
86
|
-
|
|
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
|
}
|