@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/add.ts CHANGED
@@ -1,25 +1,24 @@
1
- import { existsSync, lstatSync, mkdirSync, writeFileSync, unlinkSync } from "fs";
1
+ import { existsSync, lstatSync, mkdirSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
- import { homedir } from "os";
3
+ import { createCliRenderer } from "@opentui/core";
4
4
  import {
5
- createCliRenderer,
6
- BoxRenderable,
7
- TextRenderable,
8
- ScrollBoxRenderable,
9
- TextAttributes,
10
- type KeyEvent,
11
- } from "@opentui/core";
12
-
13
- const LIBRARY = join(homedir(), "dotfiles/skills");
14
-
15
- let repo = process.argv[3];
16
- if (repo) {
17
- // Accept GitHub URLs: https://github.com/owner/repo[/...]
18
- const urlMatch = repo.match(/github\.com\/([^/]+\/[^/]+)/);
19
- if (urlMatch) repo = urlMatch[1].replace(/\.git$/, "");
20
- }
21
- if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) {
22
- console.error("Usage: skl add owner/repo");
5
+ catalogDir,
6
+ addToLock,
7
+ downloadSkillFiles,
8
+ findSkillEntries,
9
+ parseRepoArg,
10
+ type LockEntry,
11
+ type TreeEntry,
12
+ } from "./lib.ts";
13
+ import { createAddTui, type AddSkillEntry } from "./add-tui.ts";
14
+
15
+ const CATALOG = catalogDir();
16
+
17
+ const allFlag = process.argv.includes("--all");
18
+ const repoArg = process.argv.slice(3).find((a) => !a.startsWith("-")) ?? "";
19
+ const repo = parseRepoArg(repoArg);
20
+ if (!repo) {
21
+ console.error("Usage: skl add owner/repo [--all]");
23
22
  process.exit(1);
24
23
  }
25
24
 
@@ -40,38 +39,15 @@ async function gh(args: string): Promise<string> {
40
39
  }
41
40
 
42
41
  function skillExists(name: string): boolean {
43
- const p = join(LIBRARY, name);
42
+ const p = join(CATALOG, name);
44
43
  try {
45
- const stat = lstatSync(p);
46
- if (stat.isDirectory()) return true;
47
- return false;
44
+ return lstatSync(p).isDirectory();
48
45
  } catch {
49
46
  return false;
50
47
  }
51
48
  }
52
49
 
53
- // ── Colors (matching index.ts) ───────────────────────────────────────
54
-
55
- const C = {
56
- bg: "#1a1a2e",
57
- rowBg: "#1a1a2e",
58
- rowAltBg: "#1f1f38",
59
- cursorBg: "#2a2a5a",
60
- accentBg: "#3a3a7a",
61
- border: "#444477",
62
- fg: "#ccccdd",
63
- fgDim: "#666688",
64
- checked: "#66ff88",
65
- unchecked: "#555566",
66
- warning: "#ffaa44",
67
- accent: "#8888ff",
68
- title: "#aaaaff",
69
- footer: "#888899",
70
- statusOk: "#66ff88",
71
- statusErr: "#ff6666",
72
- };
73
-
74
- // ── Fetch phase (plain stdout) ───────────────────────────────────────
50
+ // ── Fetch phase ─────────────────────────────────────────────────────
75
51
 
76
52
  console.log(`Fetching ${repo}...`);
77
53
 
@@ -87,17 +63,9 @@ try {
87
63
  const treeJson = JSON.parse(
88
64
  await gh(`repos/${repo}/git/trees/${branch}?recursive=1`)
89
65
  );
90
- const tree: { path: string; type: string }[] = treeJson.tree;
66
+ const tree: TreeEntry[] = treeJson.tree;
91
67
 
92
- // Find SKILL.md at any depth (supports both flat "name/SKILL.md" and nested "skill/name/SKILL.md")
93
- const skillEntries = tree
94
- .filter((e) => e.type === "blob" && e.path.endsWith("/SKILL.md"))
95
- .map((e) => {
96
- const prefix = e.path.replace(/\/SKILL\.md$/, ""); // e.g. "skill/opentui" or "opentui"
97
- const name = prefix.split("/").pop()!; // last segment is the skill name
98
- return { prefix, name };
99
- })
100
- .sort((a, b) => a.name.localeCompare(b.name));
68
+ const skillEntries = findSkillEntries(tree);
101
69
 
102
70
  if (skillEntries.length === 0) {
103
71
  console.error(`No skills found in ${repo} (no directories with SKILL.md)`);
@@ -106,306 +74,91 @@ if (skillEntries.length === 0) {
106
74
 
107
75
  // ── Classify skills ──────────────────────────────────────────────────
108
76
 
109
- type SkillEntry = { name: string; prefix: string; exists: boolean };
110
- const skills: SkillEntry[] = skillEntries.map(({ name, prefix }) => ({
77
+ const skills: AddSkillEntry[] = skillEntries.map(({ name, prefix, treeSHA }) => ({
111
78
  name,
112
79
  prefix,
80
+ treeSHA,
113
81
  exists: skillExists(name),
114
82
  }));
115
83
 
116
- const addableCount = skills.filter((s) => !s.exists).length;
117
- if (addableCount === 0) {
118
- console.log("All skills already exist in your library.");
84
+ const addable = skills.filter((s) => !s.exists);
85
+ if (addable.length === 0) {
86
+ console.log("All skills already exist in your catalog.");
119
87
  process.exit(0);
120
88
  }
121
89
 
122
- // ── State ────────────────────────────────────────────────────────────
123
-
124
- let cursor = 0;
125
- const checked = new Set<number>();
126
- let statusTimeout: ReturnType<typeof setTimeout> | null = null;
127
- let exitResolve: () => void;
128
-
129
- // Start cursor on first addable skill
130
- for (let i = 0; i < skills.length; i++) {
131
- if (!skills[i].exists) { cursor = i; break; }
132
- }
133
-
134
- // ── Build TUI ────────────────────────────────────────────────────────
135
-
136
- const renderer = await createCliRenderer({ exitOnCtrlC: true });
137
-
138
- const outer = new BoxRenderable(renderer, {
139
- id: "outer",
140
- width: "100%",
141
- height: "100%",
142
- flexDirection: "column",
143
- backgroundColor: C.bg,
144
- });
145
-
146
- const header = new TextRenderable(renderer, {
147
- id: "header",
148
- content: ` Add skills from ${repo}`,
149
- fg: C.title,
150
- attributes: TextAttributes.BOLD,
151
- height: 1,
152
- });
153
-
154
- const sep = new TextRenderable(renderer, {
155
- id: "sep",
156
- content: "─".repeat(60),
157
- fg: C.border,
158
- height: 1,
159
- });
160
-
161
- const scrollBox = new ScrollBoxRenderable(renderer, {
162
- id: "skill-list",
163
- flexGrow: 1,
164
- width: "100%",
165
- });
166
-
167
- type RowRefs = {
168
- row: BoxRenderable;
169
- checkText: TextRenderable;
170
- nameText: TextRenderable;
171
- };
172
- const rows: RowRefs[] = [];
173
-
174
- for (let i = 0; i < skills.length; i++) {
175
- const skill = skills[i];
176
-
177
- const row = new BoxRenderable(renderer, {
178
- id: `row-${i}`,
179
- flexDirection: "row",
180
- height: 1,
181
- width: "100%",
182
- paddingLeft: 1,
183
- backgroundColor: i % 2 === 0 ? C.rowBg : C.rowAltBg,
184
- });
185
-
186
- const checkText = new TextRenderable(renderer, {
187
- id: `check-${i}`,
188
- content: skill.exists ? "[*]" : "[ ]",
189
- fg: skill.exists ? C.fgDim : C.unchecked,
190
- width: 4,
191
- });
192
-
193
- const label = skill.exists ? `${skill.name} (exists)` : skill.name;
194
- const nameText = new TextRenderable(renderer, {
195
- id: `name-${i}`,
196
- content: ` ${label}`,
197
- fg: skill.exists ? C.fgDim : C.fg,
198
- width: 40,
199
- });
200
-
201
- row.add(checkText);
202
- row.add(nameText);
203
- scrollBox.add(row);
204
- rows.push({ row, checkText, nameText });
205
- }
206
-
207
- const footerSep = new TextRenderable(renderer, {
208
- id: "footer-sep",
209
- content: "─".repeat(60),
210
- fg: C.border,
211
- height: 1,
212
- });
213
-
214
- const footer = new TextRenderable(renderer, {
215
- id: "footer",
216
- content: " j/k move space toggle a all enter confirm q cancel",
217
- fg: C.footer,
218
- height: 1,
219
- });
220
-
221
- const statusLine = new TextRenderable(renderer, {
222
- id: "status",
223
- content: "",
224
- fg: C.statusOk,
225
- height: 1,
226
- });
227
-
228
- outer.add(header);
229
- outer.add(sep);
230
- outer.add(scrollBox);
231
- outer.add(footerSep);
232
- outer.add(footer);
233
- outer.add(statusLine);
234
- renderer.root.add(outer);
90
+ // ── Download helper ─────────────────────────────────────────────────
235
91
 
236
- // ── Display helpers ──────────────────────────────────────────────────
92
+ async function downloadSelected(selected: AddSkillEntry[]) {
93
+ mkdirSync(CATALOG, { recursive: true });
94
+ let added = 0;
237
95
 
238
- function updateRow(i: number) {
239
- const skill = skills[i];
240
- const r = rows[i];
241
- const isCursor = cursor === i;
96
+ for (const skill of selected) {
97
+ const skillDir = join(CATALOG, skill.name);
242
98
 
243
- const baseBg = i % 2 === 0 ? C.rowBg : C.rowAltBg;
244
- r.row.backgroundColor = isCursor ? C.cursorBg : baseBg;
245
-
246
- if (skill.exists) {
247
- r.checkText.content = "[*]";
248
- r.checkText.fg = C.fgDim;
249
- const pointer = isCursor ? "▸" : " ";
250
- r.nameText.content = `${pointer} ${skill.name} (exists)`;
251
- r.nameText.fg = C.fgDim;
252
- r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
253
- } else {
254
- const isChecked = checked.has(i);
255
- r.checkText.content = isChecked ? "[x]" : "[ ]";
256
- r.checkText.fg = isCursor ? C.accent : (isChecked ? C.checked : C.unchecked);
257
- const pointer = isCursor ? "▸" : " ";
258
- r.nameText.content = `${pointer} ${skill.name}`;
259
- r.nameText.fg = isCursor ? "#ffffff" : C.fg;
260
- r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
261
- }
262
- }
263
-
264
- function setStatus(msg: string, color: string) {
265
- statusLine.content = ` ${msg}`;
266
- statusLine.fg = color;
267
- if (statusTimeout) clearTimeout(statusTimeout);
268
- statusTimeout = setTimeout(() => {
269
- statusLine.content = "";
270
- }, 3000);
271
- }
272
-
273
- function selectedCount(): number {
274
- return checked.size;
275
- }
276
-
277
- function updateHeader() {
278
- header.content = ` Add skills from ${repo} (${selectedCount()} selected)`;
279
- }
280
-
281
- function refreshAll() {
282
- for (let i = 0; i < skills.length; i++) updateRow(i);
283
- updateHeader();
284
- }
285
-
286
- function ensureVisible() {
287
- scrollBox.scrollTo(Math.max(0, cursor - 2));
288
- }
289
-
290
- refreshAll();
291
-
292
- // ── Confirm & download ───────────────────────────────────────────────
293
-
294
- async function confirmAndDownload() {
295
- const selected = [...checked].map((i) => skills[i].name);
296
- if (selected.length === 0) {
297
- setStatus("Nothing selected — use space to toggle", C.warning);
298
- return;
299
- }
300
-
301
- renderer.destroy();
302
-
303
- // Download phase — plain stdout like before
304
- const baseUrl = `https://raw.githubusercontent.com/${repo}/${branch}`;
305
-
306
- for (const skillName of selected) {
307
- const skill = skills.find((s) => s.name === skillName)!;
308
- const skillDir = join(LIBRARY, skillName);
309
-
310
- // Remove broken symlink if present
311
99
  try {
312
100
  if (lstatSync(skillDir).isSymbolicLink()) {
313
- unlinkSync(skillDir);
101
+ rmSync(skillDir, { force: true });
314
102
  }
315
103
  } catch {}
316
104
 
317
105
  mkdirSync(skillDir, { recursive: true });
106
+ try {
107
+ const fileCount = await downloadSkillFiles(
108
+ repo, branch, skill.prefix, skillDir, tree,
109
+ );
110
+
111
+ const entry: LockEntry = {
112
+ source: repo,
113
+ sourceUrl: `https://github.com/${repo}`,
114
+ skillPath: skill.prefix,
115
+ treeSHA: skill.treeSHA,
116
+ addedAt: new Date().toISOString(),
117
+ };
118
+ addToLock(skill.name, entry);
119
+
120
+ console.log(` ${skill.name} — added (${fileCount} files)`);
121
+ added++;
122
+ } catch (e: any) {
123
+ console.log(` ${skill.name} — failed (${e.message})`);
124
+ }
125
+ }
318
126
 
319
- const prefix = skill.prefix + "/";
320
- const files = tree
321
- .filter((e) => e.type === "blob" && e.path.startsWith(prefix))
322
- .map((e) => e.path);
127
+ console.log(`\nAdded ${added}/${selected.length} skill(s) to ~/.skl/catalog/`);
128
+ }
323
129
 
324
- process.stdout.write(` ${skillName} (${files.length} files)...`);
130
+ // ── --all: skip TUI ─────────────────────────────────────────────────
325
131
 
326
- for (const filePath of files) {
327
- const url = `${baseUrl}/${filePath}`;
328
- const resp = await fetch(url);
329
- if (!resp.ok) {
330
- console.error(`\n Failed to fetch ${filePath}: ${resp.status}`);
331
- continue;
332
- }
333
- const content = await resp.arrayBuffer();
334
- // Remap: strip the repo prefix and save under the skill name
335
- const relativePath = filePath.substring(prefix.length);
336
- const dest = join(LIBRARY, skillName, relativePath);
337
- const dir = dest.substring(0, dest.lastIndexOf("/"));
338
- mkdirSync(dir, { recursive: true });
339
- writeFileSync(dest, Buffer.from(content));
340
- }
341
- console.log(" done");
132
+ if (allFlag) {
133
+ const skipped = skills.filter((s) => s.exists);
134
+ for (const s of skipped) {
135
+ console.log(` ${s.name} — skipped (already exists)`);
342
136
  }
343
-
344
- console.log(`\nAdded ${selected.length} skill(s) to ~/dotfiles/skills/`);
345
- exitResolve();
137
+ console.log(`Adding ${addable.length} skill(s)...`);
138
+ await downloadSelected(addable);
139
+ process.exit(0);
346
140
  }
347
141
 
348
- // ── Key handler ──────────────────────────────────────────────────────
142
+ // ── TUI ─────────────────────────────────────────────────────────────
349
143
 
350
- renderer.keyInput.on("keypress", (key: KeyEvent) => {
351
- const prevCursor = cursor;
144
+ let exitResolve: () => void;
352
145
 
353
- switch (key.name) {
354
- case "j":
355
- case "down":
356
- if (cursor < skills.length - 1) cursor++;
357
- break;
358
- case "k":
359
- case "up":
360
- if (cursor > 0) cursor--;
361
- break;
362
- case "space": {
363
- if (skills[cursor].exists) {
364
- setStatus(`${skills[cursor].name} already exists`, C.warning);
365
- break;
366
- }
367
- if (checked.has(cursor)) {
368
- checked.delete(cursor);
369
- } else {
370
- checked.add(cursor);
371
- }
372
- break;
373
- }
374
- case "a": {
375
- // Toggle all addable skills
376
- const addable = skills
377
- .map((s, i) => (!s.exists ? i : -1))
378
- .filter((i) => i >= 0);
379
- const allChecked = addable.every((i) => checked.has(i));
380
- if (allChecked) {
381
- for (const i of addable) checked.delete(i);
382
- } else {
383
- for (const i of addable) checked.add(i);
384
- }
385
- for (const i of addable) updateRow(i);
386
- updateHeader();
387
- ensureVisible();
388
- return;
389
- }
390
- case "return":
391
- confirmAndDownload();
392
- return;
393
- case "q":
394
- case "escape":
395
- renderer.destroy();
396
- exitResolve();
397
- return;
398
- default:
399
- return;
400
- }
146
+ const renderer = await createCliRenderer({ exitOnCtrlC: true });
401
147
 
402
- if (prevCursor !== cursor) updateRow(prevCursor);
403
- updateRow(cursor);
404
- updateHeader();
405
- ensureVisible();
148
+ createAddTui(renderer, {
149
+ repo,
150
+ skills,
151
+ async onConfirm(selected) {
152
+ renderer.destroy();
153
+ await downloadSelected(selected);
154
+ exitResolve();
155
+ },
156
+ onCancel() {
157
+ renderer.destroy();
158
+ exitResolve();
159
+ },
406
160
  });
407
161
 
408
- // Block module from resolving until TUI exits
409
162
  await new Promise<void>((resolve) => {
410
163
  exitResolve = resolve;
411
164
  });