@dealdeploy/skl 0.3.0 → 1.0.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.
Files changed (8) hide show
  1. package/add.ts +55 -64
  2. package/index.ts +110 -974
  3. package/lib.test.ts +110 -41
  4. package/lib.ts +125 -38
  5. package/package.json +1 -1
  6. package/tui.test.ts +565 -0
  7. package/tui.ts +612 -0
  8. package/update.ts +102 -87
package/lib.test.ts CHANGED
@@ -1,70 +1,139 @@
1
1
  import { test, expect, beforeEach, afterEach } from "bun:test";
2
- import { mkdirSync, rmSync, existsSync, writeFileSync, symlinkSync } from "fs";
2
+ import { mkdirSync, rmSync, writeFileSync } from "fs";
3
3
  import { join } from "path";
4
4
  import { mkdtempSync } from "fs";
5
5
  import { tmpdir } from "os";
6
6
 
7
- import { findOrphanSkills, adoptSkills } from "./lib.ts";
7
+ import {
8
+ readLock,
9
+ writeLock,
10
+ addToLock,
11
+ removeFromLock,
12
+ getCatalogSkills,
13
+ getLockEntry,
14
+ catalogDir,
15
+ type LockFile,
16
+ type LockEntry,
17
+ } from "./lib.ts";
8
18
 
9
19
  let tmp: string;
10
- let library: string;
11
- let localDir: string;
20
+ let origHome: string;
12
21
 
13
22
  beforeEach(() => {
14
23
  tmp = mkdtempSync(join(tmpdir(), "skl-test-"));
15
- library = join(tmp, "library");
16
- localDir = join(tmp, "local");
17
- mkdirSync(library);
18
- mkdirSync(localDir);
24
+ origHome = process.env.HOME!;
25
+ process.env.HOME = tmp;
19
26
  });
20
27
 
21
28
  afterEach(() => {
29
+ process.env.HOME = origHome;
22
30
  rmSync(tmp, { recursive: true, force: true });
23
31
  });
24
32
 
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);
33
+ test("readLock returns empty structure when file doesn't exist", () => {
34
+ const lock = readLock();
35
+ expect(lock).toEqual({ version: 1, skills: {} });
36
+ });
30
37
 
31
- expect(orphans).toEqual([{ dir: localDir, name: "my-skill" }]);
38
+ test("writeLock and readLock round-trip", () => {
39
+ const lock: LockFile = {
40
+ version: 1,
41
+ skills: {
42
+ "test-skill": {
43
+ source: "owner/repo",
44
+ sourceUrl: "https://github.com/owner/repo",
45
+ skillPath: "skills/test-skill",
46
+ treeSHA: "abc123",
47
+ addedAt: "2026-03-12T00:00:00.000Z",
48
+ },
49
+ },
50
+ };
51
+ writeLock(lock);
52
+ const read = readLock();
53
+ expect(read).toEqual(lock);
32
54
  });
33
55
 
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"));
56
+ test("addToLock adds a new entry", () => {
57
+ writeLock({ version: 1, skills: {} });
39
58
 
40
- const orphans = findOrphanSkills([localDir, localDir2], library);
59
+ const entry: LockEntry = {
60
+ source: "owner/repo",
61
+ sourceUrl: "https://github.com/owner/repo",
62
+ skillPath: "skills/my-skill",
63
+ treeSHA: "def456",
64
+ addedAt: "2026-03-12T00:00:00.000Z",
65
+ };
66
+ addToLock("my-skill", entry);
41
67
 
42
- expect(orphans).toHaveLength(1);
43
- expect(orphans[0]!.name).toBe("shared-skill");
68
+ const lock = readLock();
69
+ expect(lock.skills["my-skill"]).toEqual(entry);
44
70
  });
45
71
 
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);
72
+ test("removeFromLock removes an entry", () => {
73
+ writeLock({
74
+ version: 1,
75
+ skills: {
76
+ "keep-me": {
77
+ source: "a/b",
78
+ sourceUrl: "https://github.com/a/b",
79
+ skillPath: "skills/keep-me",
80
+ treeSHA: "111",
81
+ addedAt: "2026-03-12T00:00:00.000Z",
82
+ },
83
+ "remove-me": {
84
+ source: "a/b",
85
+ sourceUrl: "https://github.com/a/b",
86
+ skillPath: "skills/remove-me",
87
+ treeSHA: "222",
88
+ addedAt: "2026-03-12T00:00:00.000Z",
89
+ },
90
+ },
91
+ });
92
+
93
+ removeFromLock("remove-me");
94
+ const lock = readLock();
95
+ expect(lock.skills["keep-me"]).toBeDefined();
96
+ expect(lock.skills["remove-me"]).toBeUndefined();
97
+ });
53
98
 
54
- expect(results).toEqual([{ name: "cool-skill", copied: true }]);
55
- expect(existsSync(join(library, "cool-skill", "prompt.md"))).toBe(true);
99
+ test("getLockEntry returns entry or null", () => {
100
+ writeLock({
101
+ version: 1,
102
+ skills: {
103
+ "exists": {
104
+ source: "a/b",
105
+ sourceUrl: "https://github.com/a/b",
106
+ skillPath: "skills/exists",
107
+ treeSHA: "333",
108
+ addedAt: "2026-03-12T00:00:00.000Z",
109
+ },
110
+ },
111
+ });
112
+
113
+ expect(getLockEntry("exists")).not.toBeNull();
114
+ expect(getLockEntry("exists")!.treeSHA).toBe("333");
115
+ expect(getLockEntry("nope")).toBeNull();
56
116
  });
57
117
 
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"));
118
+ test("getCatalogSkills finds directories with SKILL.md", () => {
119
+ const catalog = catalogDir();
120
+ mkdirSync(catalog, { recursive: true });
121
+
122
+ mkdirSync(join(catalog, "good-skill"));
123
+ writeFileSync(join(catalog, "good-skill", "SKILL.md"), "# Good");
66
124
 
67
- const orphans = findOrphanSkills([localDir], library);
125
+ mkdirSync(join(catalog, "no-manifest"));
126
+
127
+ writeFileSync(join(catalog, "just-a-file"), "nope");
128
+
129
+ mkdirSync(join(catalog, "another-skill"));
130
+ writeFileSync(join(catalog, "another-skill", "SKILL.md"), "# Another");
131
+
132
+ const skills = getCatalogSkills();
133
+ expect(skills).toEqual(["another-skill", "good-skill"]);
134
+ });
68
135
 
69
- expect(orphans).toEqual([{ dir: localDir, name: "real-skill" }]);
136
+ test("getCatalogSkills returns empty array when catalog doesn't exist", () => {
137
+ const skills = getCatalogSkills();
138
+ expect(skills).toEqual([]);
70
139
  });
package/lib.ts CHANGED
@@ -1,50 +1,137 @@
1
- import { readdirSync, lstatSync, existsSync, cpSync } from "fs";
1
+ import { readdirSync, existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
2
2
  import { join } from "path";
3
+ import { homedir } from "os";
3
4
 
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;
5
+ function home(): string {
6
+ return process.env.HOME || homedir();
7
+ }
8
+
9
+ export function catalogDir(): string {
10
+ return join(home(), ".skl/catalog");
11
+ }
12
+
13
+ export function lockPath(): string {
14
+ return join(home(), ".skl/catalog.lock.json");
15
+ }
16
+
17
+ export type LockEntry = {
18
+ source: string;
19
+ sourceUrl: string;
20
+ skillPath: string;
21
+ treeSHA: string;
22
+ addedAt: string;
23
+ };
24
+
25
+ export type LockFile = {
26
+ version: 1;
27
+ skills: Record<string, LockEntry>;
28
+ };
29
+
30
+ export function readLock(): LockFile {
31
+ try {
32
+ const data = JSON.parse(readFileSync(lockPath(), "utf-8"));
33
+ if (data.version === 1 && data.skills) return data;
34
+ } catch {}
35
+ return { version: 1, skills: {} };
36
+ }
37
+
38
+ export function writeLock(lock: LockFile): void {
39
+ mkdirSync(join(home(), ".skl"), { recursive: true });
40
+ writeFileSync(lockPath(), JSON.stringify(lock, null, 2) + "\n");
41
+ }
42
+
43
+ export function addToLock(name: string, entry: LockEntry): void {
44
+ const lock = readLock();
45
+ lock.skills[name] = entry;
46
+ writeLock(lock);
47
+ }
48
+
49
+ export function removeFromLock(name: string): void {
50
+ const lock = readLock();
51
+ delete lock.skills[name];
52
+ writeLock(lock);
53
+ }
54
+
55
+ export function getLockEntry(name: string): LockEntry | null {
56
+ const lock = readLock();
57
+ return lock.skills[name] ?? null;
58
+ }
59
+
60
+ export function getCatalogSkills(): string[] {
61
+ const dir = catalogDir();
62
+ try {
63
+ return readdirSync(dir)
64
+ .filter((name) => {
15
65
  const full = join(dir, name);
16
66
  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 {}
67
+ return (
68
+ statSync(full).isDirectory() &&
69
+ existsSync(join(full, "SKILL.md"))
70
+ );
71
+ } catch {
72
+ return false;
73
+ }
74
+ })
75
+ .sort();
76
+ } catch {
77
+ return [];
27
78
  }
79
+ }
80
+
81
+ export async function fetchTreeSHA(
82
+ ownerRepo: string,
83
+ skillPath: string,
84
+ token?: string,
85
+ ): Promise<string | null> {
86
+ const headers: Record<string, string> = {
87
+ Accept: "application/vnd.github+json",
88
+ };
89
+ if (token) headers.Authorization = `Bearer ${token}`;
90
+
91
+ const repoResp = await fetch(`https://api.github.com/repos/${ownerRepo}`, { headers });
92
+ if (!repoResp.ok) return null;
93
+ const repoData = (await repoResp.json()) as { default_branch: string };
94
+ const branch = repoData.default_branch;
28
95
 
29
- return orphans;
96
+ const treeResp = await fetch(
97
+ `https://api.github.com/repos/${ownerRepo}/git/trees/${branch}?recursive=1`,
98
+ { headers },
99
+ );
100
+ if (!treeResp.ok) return null;
101
+ const treeData = (await treeResp.json()) as { tree?: { path: string; type: string; sha: string }[] };
102
+
103
+ const entry = treeData.tree?.find(
104
+ (e: { path: string; type: string; sha: string }) =>
105
+ e.path === skillPath && e.type === "tree",
106
+ );
107
+ return entry?.sha ?? null;
30
108
  }
31
109
 
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 }[] = [];
110
+ export async function downloadSkillFiles(
111
+ repo: string,
112
+ branch: string,
113
+ prefix: string,
114
+ destDir: string,
115
+ tree: { path: string; type: string }[],
116
+ ): Promise<number> {
117
+ const prefixSlash = prefix + "/";
118
+ const files = tree
119
+ .filter((e) => e.type === "blob" && e.path.startsWith(prefixSlash))
120
+ .map((e) => e.path);
121
+
122
+ const baseUrl = `https://raw.githubusercontent.com/${repo}/${branch}`;
37
123
 
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
- }
124
+ for (const filePath of files) {
125
+ const url = `${baseUrl}/${filePath}`;
126
+ const resp = await fetch(url);
127
+ if (!resp.ok) continue;
128
+ const content = await resp.arrayBuffer();
129
+ const relativePath = filePath.substring(prefixSlash.length);
130
+ const dest = join(destDir, relativePath);
131
+ const dir = dest.substring(0, dest.lastIndexOf("/"));
132
+ mkdirSync(dir, { recursive: true });
133
+ writeFileSync(dest, Buffer.from(content));
47
134
  }
48
135
 
49
- return results;
136
+ return files.length;
50
137
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dealdeploy/skl",
3
- "version": "0.3.0",
3
+ "version": "1.0.0",
4
4
  "description": "TUI skill manager for Claude Code agents",
5
5
  "module": "index.ts",
6
6
  "bin": {