@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/add.ts CHANGED
@@ -1,6 +1,5 @@
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";
4
3
  import {
5
4
  createCliRenderer,
6
5
  BoxRenderable,
@@ -9,14 +8,14 @@ import {
9
8
  TextAttributes,
10
9
  type KeyEvent,
11
10
  } from "@opentui/core";
11
+ import { catalogDir, addToLock, downloadSkillFiles, type LockEntry } from "./lib.ts";
12
12
 
13
- const LIBRARY = join(homedir(), "dotfiles/skills");
13
+ const CATALOG = catalogDir();
14
14
 
15
- let repo = process.argv[3];
15
+ let repo = process.argv[3] ?? "";
16
16
  if (repo) {
17
- // Accept GitHub URLs: https://github.com/owner/repo[/...]
18
17
  const urlMatch = repo.match(/github\.com\/([^/]+\/[^/]+)/);
19
- if (urlMatch) repo = urlMatch[1].replace(/\.git$/, "");
18
+ if (urlMatch) repo = urlMatch[1]!.replace(/\.git$/, "");
20
19
  }
21
20
  if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) {
22
21
  console.error("Usage: skl add owner/repo");
@@ -40,17 +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) ───────────────────────────────────────
50
+ // ── Colors ───────────────────────────────────────────────────────────
54
51
 
55
52
  const C = {
56
53
  bg: "#1a1a2e",
@@ -71,7 +68,7 @@ const C = {
71
68
  statusErr: "#ff6666",
72
69
  };
73
70
 
74
- // ── Fetch phase (plain stdout) ───────────────────────────────────────
71
+ // ── Fetch phase ─────────────────────────────────────────────────────
75
72
 
76
73
  console.log(`Fetching ${repo}...`);
77
74
 
@@ -87,15 +84,19 @@ try {
87
84
  const treeJson = JSON.parse(
88
85
  await gh(`repos/${repo}/git/trees/${branch}?recursive=1`)
89
86
  );
90
- const tree: { path: string; type: string }[] = treeJson.tree;
87
+ const tree: { path: string; type: string; sha: string }[] = treeJson.tree;
91
88
 
92
- // Find SKILL.md at any depth (supports both flat "name/SKILL.md" and nested "skill/name/SKILL.md")
89
+ // Find SKILL.md at any depth
93
90
  const skillEntries = tree
94
91
  .filter((e) => e.type === "blob" && e.path.endsWith("/SKILL.md"))
95
92
  .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 };
93
+ const prefix = e.path.replace(/\/SKILL\.md$/, "");
94
+ const name = prefix.split("/").pop()!;
95
+ // Find the tree SHA for this skill's directory
96
+ const dirEntry = tree.find(
97
+ (t) => t.path === prefix && t.type === "tree"
98
+ );
99
+ return { prefix, name, treeSHA: dirEntry?.sha ?? "" };
99
100
  })
100
101
  .sort((a, b) => a.name.localeCompare(b.name));
101
102
 
@@ -106,16 +107,17 @@ if (skillEntries.length === 0) {
106
107
 
107
108
  // ── Classify skills ──────────────────────────────────────────────────
108
109
 
109
- type SkillEntry = { name: string; prefix: string; exists: boolean };
110
- const skills: SkillEntry[] = skillEntries.map(({ name, prefix }) => ({
110
+ type SkillEntry = { name: string; prefix: string; treeSHA: string; exists: boolean };
111
+ const skills: SkillEntry[] = skillEntries.map(({ name, prefix, treeSHA }) => ({
111
112
  name,
112
113
  prefix,
114
+ treeSHA,
113
115
  exists: skillExists(name),
114
116
  }));
115
117
 
116
118
  const addableCount = skills.filter((s) => !s.exists).length;
117
119
  if (addableCount === 0) {
118
- console.log("All skills already exist in your library.");
120
+ console.log("All skills already exist in your catalog.");
119
121
  process.exit(0);
120
122
  }
121
123
 
@@ -126,9 +128,8 @@ const checked = new Set<number>();
126
128
  let statusTimeout: ReturnType<typeof setTimeout> | null = null;
127
129
  let exitResolve: () => void;
128
130
 
129
- // Start cursor on first addable skill
130
131
  for (let i = 0; i < skills.length; i++) {
131
- if (!skills[i].exists) { cursor = i; break; }
132
+ if (!skills[i]!.exists) { cursor = i; break; }
132
133
  }
133
134
 
134
135
  // ── Build TUI ────────────────────────────────────────────────────────
@@ -153,7 +154,7 @@ const header = new TextRenderable(renderer, {
153
154
 
154
155
  const sep = new TextRenderable(renderer, {
155
156
  id: "sep",
156
- content: "".repeat(60),
157
+ content: "\u2500".repeat(60),
157
158
  fg: C.border,
158
159
  height: 1,
159
160
  });
@@ -172,7 +173,7 @@ type RowRefs = {
172
173
  const rows: RowRefs[] = [];
173
174
 
174
175
  for (let i = 0; i < skills.length; i++) {
175
- const skill = skills[i];
176
+ const skill = skills[i]!;
176
177
 
177
178
  const row = new BoxRenderable(renderer, {
178
179
  id: `row-${i}`,
@@ -206,7 +207,7 @@ for (let i = 0; i < skills.length; i++) {
206
207
 
207
208
  const footerSep = new TextRenderable(renderer, {
208
209
  id: "footer-sep",
209
- content: "".repeat(60),
210
+ content: "\u2500".repeat(60),
210
211
  fg: C.border,
211
212
  height: 1,
212
213
  });
@@ -236,8 +237,8 @@ renderer.root.add(outer);
236
237
  // ── Display helpers ──────────────────────────────────────────────────
237
238
 
238
239
  function updateRow(i: number) {
239
- const skill = skills[i];
240
- const r = rows[i];
240
+ const skill = skills[i]!;
241
+ const r = rows[i]!;
241
242
  const isCursor = cursor === i;
242
243
 
243
244
  const baseBg = i % 2 === 0 ? C.rowBg : C.rowAltBg;
@@ -246,7 +247,7 @@ function updateRow(i: number) {
246
247
  if (skill.exists) {
247
248
  r.checkText.content = "[*]";
248
249
  r.checkText.fg = C.fgDim;
249
- const pointer = isCursor ? "" : " ";
250
+ const pointer = isCursor ? "\u25b8" : " ";
250
251
  r.nameText.content = `${pointer} ${skill.name} (exists)`;
251
252
  r.nameText.fg = C.fgDim;
252
253
  r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
@@ -254,7 +255,7 @@ function updateRow(i: number) {
254
255
  const isChecked = checked.has(i);
255
256
  r.checkText.content = isChecked ? "[x]" : "[ ]";
256
257
  r.checkText.fg = isCursor ? C.accent : (isChecked ? C.checked : C.unchecked);
257
- const pointer = isCursor ? "" : " ";
258
+ const pointer = isCursor ? "\u25b8" : " ";
258
259
  r.nameText.content = `${pointer} ${skill.name}`;
259
260
  r.nameText.fg = isCursor ? "#ffffff" : C.fg;
260
261
  r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
@@ -292,56 +293,48 @@ refreshAll();
292
293
  // ── Confirm & download ───────────────────────────────────────────────
293
294
 
294
295
  async function confirmAndDownload() {
295
- const selected = [...checked].map((i) => skills[i].name);
296
+ const selected = [...checked].map((i) => skills[i]!);
296
297
  if (selected.length === 0) {
297
- setStatus("Nothing selected use space to toggle", C.warning);
298
+ setStatus("Nothing selected \u2014 use space to toggle", C.warning);
298
299
  return;
299
300
  }
300
301
 
301
302
  renderer.destroy();
302
303
 
303
- // Download phase plain stdout like before
304
- const baseUrl = `https://raw.githubusercontent.com/${repo}/${branch}`;
304
+ mkdirSync(CATALOG, { recursive: true });
305
305
 
306
- for (const skillName of selected) {
307
- const skill = skills.find((s) => s.name === skillName)!;
308
- const skillDir = join(LIBRARY, skillName);
306
+ for (const skill of selected) {
307
+ const skillDir = join(CATALOG, skill.name);
309
308
 
310
- // Remove broken symlink if present
309
+ // Remove broken remnant if present
311
310
  try {
312
311
  if (lstatSync(skillDir).isSymbolicLink()) {
313
- unlinkSync(skillDir);
312
+ rmSync(skillDir, { force: true });
314
313
  }
315
314
  } catch {}
316
315
 
317
316
  mkdirSync(skillDir, { recursive: true });
318
317
 
319
- const prefix = skill.prefix + "/";
320
- const files = tree
321
- .filter((e) => e.type === "blob" && e.path.startsWith(prefix))
322
- .map((e) => e.path);
318
+ process.stdout.write(` ${skill.name}...`);
323
319
 
324
- process.stdout.write(` ${skillName} (${files.length} files)...`);
320
+ const fileCount = await downloadSkillFiles(
321
+ repo!, branch, skill.prefix, skillDir, tree,
322
+ );
325
323
 
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");
324
+ // Write lock entry
325
+ const entry: LockEntry = {
326
+ source: repo!,
327
+ sourceUrl: `https://github.com/${repo}`,
328
+ skillPath: skill.prefix,
329
+ treeSHA: skill.treeSHA,
330
+ addedAt: new Date().toISOString(),
331
+ };
332
+ addToLock(skill.name, entry);
333
+
334
+ console.log(` done (${fileCount} files)`);
342
335
  }
343
336
 
344
- console.log(`\nAdded ${selected.length} skill(s) to ~/dotfiles/skills/`);
337
+ console.log(`\nAdded ${selected.length} skill(s) to ~/.skl/catalog/`);
345
338
  exitResolve();
346
339
  }
347
340
 
@@ -360,8 +353,8 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
360
353
  if (cursor > 0) cursor--;
361
354
  break;
362
355
  case "space": {
363
- if (skills[cursor].exists) {
364
- setStatus(`${skills[cursor].name} already exists`, C.warning);
356
+ if (skills[cursor]!.exists) {
357
+ setStatus(`${skills[cursor]!.name} already exists`, C.warning);
365
358
  break;
366
359
  }
367
360
  if (checked.has(cursor)) {
@@ -372,7 +365,6 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
372
365
  break;
373
366
  }
374
367
  case "a": {
375
- // Toggle all addable skills
376
368
  const addable = skills
377
369
  .map((s, i) => (!s.exists ? i : -1))
378
370
  .filter((i) => i >= 0);
@@ -405,7 +397,6 @@ renderer.keyInput.on("keypress", (key: KeyEvent) => {
405
397
  ensureVisible();
406
398
  });
407
399
 
408
- // Block module from resolving until TUI exits
409
400
  await new Promise<void>((resolve) => {
410
401
  exitResolve = resolve;
411
402
  });