@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-tui.test.ts +338 -0
- package/add-tui.ts +269 -0
- package/add.ts +81 -328
- package/index.ts +112 -976
- package/lib.test.ts +470 -40
- package/lib.ts +240 -37
- package/package.json +1 -1
- package/skills-lock.json +10 -0
- package/tui.test.ts +565 -0
- package/tui.ts +612 -0
- package/update.ts +90 -90
package/add.ts
CHANGED
|
@@ -1,25 +1,24 @@
|
|
|
1
|
-
import { existsSync, lstatSync, mkdirSync,
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, rmSync } from "fs";
|
|
2
2
|
import { join } from "path";
|
|
3
|
-
import {
|
|
3
|
+
import { createCliRenderer } from "@opentui/core";
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
type
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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
|
-
// ──
|
|
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:
|
|
66
|
+
const tree: TreeEntry[] = treeJson.tree;
|
|
91
67
|
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
if (
|
|
118
|
-
console.log("All skills already exist in your
|
|
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
|
-
// ──
|
|
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
|
-
|
|
92
|
+
async function downloadSelected(selected: AddSkillEntry[]) {
|
|
93
|
+
mkdirSync(CATALOG, { recursive: true });
|
|
94
|
+
let added = 0;
|
|
237
95
|
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
130
|
+
// ── --all: skip TUI ─────────────────────────────────────────────────
|
|
325
131
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
345
|
-
|
|
137
|
+
console.log(`Adding ${addable.length} skill(s)...`);
|
|
138
|
+
await downloadSelected(addable);
|
|
139
|
+
process.exit(0);
|
|
346
140
|
}
|
|
347
141
|
|
|
348
|
-
// ──
|
|
142
|
+
// ── TUI ─────────────────────────────────────────────────────────────
|
|
349
143
|
|
|
350
|
-
|
|
351
|
-
const prevCursor = cursor;
|
|
144
|
+
let exitResolve: () => void;
|
|
352
145
|
|
|
353
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
});
|