@dealdeploy/skl 0.4.0 → 1.1.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/lib.ts CHANGED
@@ -1,50 +1,253 @@
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
+ // ── Tree / skill discovery ────────────────────────────────────────────
82
+
83
+ export type TreeEntry = { path: string; type: string; sha: string };
28
84
 
29
- return orphans;
85
+ export type SkillEntry = {
86
+ name: string;
87
+ prefix: string;
88
+ treeSHA: string;
89
+ };
90
+
91
+ /** Find all skill directories (containing SKILL.md) in a GitHub tree. */
92
+ export function findSkillEntries(tree: TreeEntry[]): SkillEntry[] {
93
+ return tree
94
+ .filter((e) => e.type === "blob" && e.path.endsWith("/SKILL.md"))
95
+ .map((e) => {
96
+ const prefix = e.path.replace(/\/SKILL\.md$/, "");
97
+ const name = prefix.split("/").pop()!;
98
+ const dirEntry = tree.find(
99
+ (t) => t.path === prefix && t.type === "tree"
100
+ );
101
+ return { prefix, name, treeSHA: dirEntry?.sha ?? "" };
102
+ })
103
+ .sort((a, b) => a.name.localeCompare(b.name));
30
104
  }
31
105
 
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 }[] = [];
106
+ /** Parse a repo argument: "owner/repo" or GitHub URL → "owner/repo". Returns null if invalid. */
107
+ export function parseRepoArg(input: string): string | null {
108
+ let repo = input;
109
+ const urlMatch = repo.match(/github\.com\/([^/]+\/[^/]+)/);
110
+ if (urlMatch) repo = urlMatch[1]!.replace(/\.git$/, "");
111
+ if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) return null;
112
+ return repo;
113
+ }
37
114
 
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 });
115
+ /** Build npx skills add args for a skill. */
116
+ export function buildAddArgs(
117
+ catalogPath: string,
118
+ name: string,
119
+ isGlobal: boolean,
120
+ ): string[] {
121
+ const lockEntry = getLockEntry(name);
122
+ if (lockEntry) {
123
+ const args = ["add", lockEntry.sourceUrl, "--skill", name, "-y"];
124
+ if (isGlobal) args.push("-g");
125
+ return args;
126
+ }
127
+ const args = ["add", join(catalogPath, name), "-y"];
128
+ if (isGlobal) args.push("-g");
129
+ return args;
130
+ }
131
+
132
+ /** Parse JSON output from `npx skills list --json`. */
133
+ export function parseSkillsListOutput(jsonStr: string): string[] {
134
+ try {
135
+ const data = JSON.parse(jsonStr);
136
+ if (Array.isArray(data)) {
137
+ return data.map((s: { name: string }) => s.name);
138
+ }
139
+ } catch {}
140
+ return [];
141
+ }
142
+
143
+ // ── Update planning ─────────────────────────────────────────────────
144
+
145
+ export type UpdatePlan = {
146
+ updated: { name: string; skillPath: string; oldSHA: string; newSHA: string }[];
147
+ upToDate: string[];
148
+ notFound: string[];
149
+ };
150
+
151
+ /** Compare lock entries against a remote tree and plan which skills need updating. */
152
+ export function planUpdates(
153
+ skills: { name: string; skillPath: string; treeSHA: string }[],
154
+ remoteTree: TreeEntry[],
155
+ ): UpdatePlan {
156
+ const updated: UpdatePlan["updated"] = [];
157
+ const upToDate: string[] = [];
158
+ const notFound: string[] = [];
159
+
160
+ for (const skill of skills) {
161
+ const remoteEntry = remoteTree.find(
162
+ (e) => e.path === skill.skillPath && e.type === "tree"
163
+ );
164
+ if (!remoteEntry) {
165
+ notFound.push(skill.name);
166
+ continue;
167
+ }
168
+ if (remoteEntry.sha === skill.treeSHA) {
169
+ upToDate.push(skill.name);
170
+ } else {
171
+ updated.push({
172
+ name: skill.name,
173
+ skillPath: skill.skillPath,
174
+ oldSHA: skill.treeSHA,
175
+ newSHA: remoteEntry.sha,
176
+ });
46
177
  }
47
178
  }
48
179
 
49
- return results;
180
+ return { updated, upToDate, notFound };
181
+ }
182
+
183
+ /** Group lock file skills by source repo. */
184
+ export function groupByRepo(
185
+ lock: LockFile,
186
+ ): Map<string, { name: string; skillPath: string; treeSHA: string }[]> {
187
+ const byRepo = new Map<string, { name: string; skillPath: string; treeSHA: string }[]>();
188
+ for (const [name, entry] of Object.entries(lock.skills)) {
189
+ if (!entry.source || !entry.treeSHA) continue;
190
+ const list = byRepo.get(entry.source) ?? [];
191
+ list.push({ name, skillPath: entry.skillPath, treeSHA: entry.treeSHA });
192
+ byRepo.set(entry.source, list);
193
+ }
194
+ return byRepo;
195
+ }
196
+
197
+ export async function fetchTreeSHA(
198
+ ownerRepo: string,
199
+ skillPath: string,
200
+ token?: string,
201
+ ): Promise<string | null> {
202
+ const headers: Record<string, string> = {
203
+ Accept: "application/vnd.github+json",
204
+ };
205
+ if (token) headers.Authorization = `Bearer ${token}`;
206
+
207
+ const repoResp = await fetch(`https://api.github.com/repos/${ownerRepo}`, { headers });
208
+ if (!repoResp.ok) return null;
209
+ const repoData = (await repoResp.json()) as { default_branch: string };
210
+ const branch = repoData.default_branch;
211
+
212
+ const treeResp = await fetch(
213
+ `https://api.github.com/repos/${ownerRepo}/git/trees/${branch}?recursive=1`,
214
+ { headers },
215
+ );
216
+ if (!treeResp.ok) return null;
217
+ const treeData = (await treeResp.json()) as { tree?: { path: string; type: string; sha: string }[] };
218
+
219
+ const entry = treeData.tree?.find(
220
+ (e: { path: string; type: string; sha: string }) =>
221
+ e.path === skillPath && e.type === "tree",
222
+ );
223
+ return entry?.sha ?? null;
224
+ }
225
+
226
+ export async function downloadSkillFiles(
227
+ repo: string,
228
+ branch: string,
229
+ prefix: string,
230
+ destDir: string,
231
+ tree: { path: string; type: string }[],
232
+ ): Promise<number> {
233
+ const prefixSlash = prefix + "/";
234
+ const files = tree
235
+ .filter((e) => e.type === "blob" && e.path.startsWith(prefixSlash))
236
+ .map((e) => e.path);
237
+
238
+ const baseUrl = `https://raw.githubusercontent.com/${repo}/${branch}`;
239
+
240
+ for (const filePath of files) {
241
+ const url = `${baseUrl}/${filePath}`;
242
+ const resp = await fetch(url);
243
+ if (!resp.ok) continue;
244
+ const content = await resp.arrayBuffer();
245
+ const relativePath = filePath.substring(prefixSlash.length);
246
+ const dest = join(destDir, relativePath);
247
+ const dir = dest.substring(0, dest.lastIndexOf("/"));
248
+ mkdirSync(dir, { recursive: true });
249
+ writeFileSync(dest, Buffer.from(content));
250
+ }
251
+
252
+ return files.length;
50
253
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dealdeploy/skl",
3
- "version": "0.4.0",
3
+ "version": "1.1.0",
4
4
  "description": "TUI skill manager for Claude Code agents",
5
5
  "module": "index.ts",
6
6
  "bin": {
@@ -0,0 +1,10 @@
1
+ {
2
+ "version": 1,
3
+ "skills": {
4
+ "agent-browser": {
5
+ "source": "vercel-labs/agent-browser",
6
+ "sourceType": "github",
7
+ "computedHash": "46eddb8bcb7de8e7c75ff53ee54bf764a576a923d19765b4ef6a71bb04755db6"
8
+ }
9
+ }
10
+ }