@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.
- package/add.ts +55 -64
- package/index.ts +110 -974
- package/lib.test.ts +110 -41
- package/lib.ts +125 -38
- package/package.json +1 -1
- package/tui.test.ts +565 -0
- package/tui.ts +612 -0
- 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,
|
|
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 {
|
|
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
|
|
11
|
-
let localDir: string;
|
|
20
|
+
let origHome: string;
|
|
12
21
|
|
|
13
22
|
beforeEach(() => {
|
|
14
23
|
tmp = mkdtempSync(join(tmpdir(), "skl-test-"));
|
|
15
|
-
|
|
16
|
-
|
|
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("
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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("
|
|
35
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
expect(
|
|
68
|
+
const lock = readLock();
|
|
69
|
+
expect(lock.skills["my-skill"]).toEqual(entry);
|
|
44
70
|
});
|
|
45
71
|
|
|
46
|
-
test("
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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("
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
mkdirSync(join(
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
1
|
+
import { readdirSync, existsSync, readFileSync, writeFileSync, mkdirSync, statSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
+
import { homedir } from "os";
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
136
|
+
return files.length;
|
|
50
137
|
}
|