@dealdeploy/skl 0.4.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 -972
- 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/add.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync,
|
|
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
|
|
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]
|
|
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(
|
|
42
|
+
const p = join(CATALOG, name);
|
|
44
43
|
try {
|
|
45
|
-
|
|
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
|
|
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
|
|
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
|
|
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$/, "");
|
|
97
|
-
const name = prefix.split("/").pop()!;
|
|
98
|
-
|
|
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
|
|
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]
|
|
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: "
|
|
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: "
|
|
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]
|
|
296
|
+
const selected = [...checked].map((i) => skills[i]!);
|
|
296
297
|
if (selected.length === 0) {
|
|
297
|
-
setStatus("Nothing selected
|
|
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
|
-
|
|
304
|
-
const baseUrl = `https://raw.githubusercontent.com/${repo}/${branch}`;
|
|
304
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
305
305
|
|
|
306
|
-
for (const
|
|
307
|
-
const
|
|
308
|
-
const skillDir = join(LIBRARY, skillName);
|
|
306
|
+
for (const skill of selected) {
|
|
307
|
+
const skillDir = join(CATALOG, skill.name);
|
|
309
308
|
|
|
310
|
-
// Remove broken
|
|
309
|
+
// Remove broken remnant if present
|
|
311
310
|
try {
|
|
312
311
|
if (lstatSync(skillDir).isSymbolicLink()) {
|
|
313
|
-
|
|
312
|
+
rmSync(skillDir, { force: true });
|
|
314
313
|
}
|
|
315
314
|
} catch {}
|
|
316
315
|
|
|
317
316
|
mkdirSync(skillDir, { recursive: true });
|
|
318
317
|
|
|
319
|
-
|
|
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
|
-
|
|
320
|
+
const fileCount = await downloadSkillFiles(
|
|
321
|
+
repo!, branch, skill.prefix, skillDir, tree,
|
|
322
|
+
);
|
|
325
323
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
|
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]
|
|
364
|
-
setStatus(`${skills[cursor]
|
|
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
|
});
|