@dealdeploy/skl 1.0.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,24 +1,24 @@
1
1
  import { existsSync, lstatSync, mkdirSync, rmSync } from "fs";
2
2
  import { join } from "path";
3
+ import { createCliRenderer } from "@opentui/core";
3
4
  import {
4
- createCliRenderer,
5
- BoxRenderable,
6
- TextRenderable,
7
- ScrollBoxRenderable,
8
- TextAttributes,
9
- type KeyEvent,
10
- } from "@opentui/core";
11
- import { catalogDir, addToLock, downloadSkillFiles, type LockEntry } from "./lib.ts";
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";
12
14
 
13
15
  const CATALOG = catalogDir();
14
16
 
15
- let repo = process.argv[3] ?? "";
16
- if (repo) {
17
- const urlMatch = repo.match(/github\.com\/([^/]+\/[^/]+)/);
18
- if (urlMatch) repo = urlMatch[1]!.replace(/\.git$/, "");
19
- }
20
- if (!repo || !/^[^/]+\/[^/]+$/.test(repo)) {
21
- console.error("Usage: skl add owner/repo");
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]");
22
22
  process.exit(1);
23
23
  }
24
24
 
@@ -47,27 +47,6 @@ function skillExists(name: string): boolean {
47
47
  }
48
48
  }
49
49
 
50
- // ── Colors ───────────────────────────────────────────────────────────
51
-
52
- const C = {
53
- bg: "#1a1a2e",
54
- rowBg: "#1a1a2e",
55
- rowAltBg: "#1f1f38",
56
- cursorBg: "#2a2a5a",
57
- accentBg: "#3a3a7a",
58
- border: "#444477",
59
- fg: "#ccccdd",
60
- fgDim: "#666688",
61
- checked: "#66ff88",
62
- unchecked: "#555566",
63
- warning: "#ffaa44",
64
- accent: "#8888ff",
65
- title: "#aaaaff",
66
- footer: "#888899",
67
- statusOk: "#66ff88",
68
- statusErr: "#ff6666",
69
- };
70
-
71
50
  // ── Fetch phase ─────────────────────────────────────────────────────
72
51
 
73
52
  console.log(`Fetching ${repo}...`);
@@ -84,21 +63,9 @@ try {
84
63
  const treeJson = JSON.parse(
85
64
  await gh(`repos/${repo}/git/trees/${branch}?recursive=1`)
86
65
  );
87
- const tree: { path: string; type: string; sha: string }[] = treeJson.tree;
66
+ const tree: TreeEntry[] = treeJson.tree;
88
67
 
89
- // Find SKILL.md at any depth
90
- const skillEntries = tree
91
- .filter((e) => e.type === "blob" && e.path.endsWith("/SKILL.md"))
92
- .map((e) => {
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 ?? "" };
100
- })
101
- .sort((a, b) => a.name.localeCompare(b.name));
68
+ const skillEntries = findSkillEntries(tree);
102
69
 
103
70
  if (skillEntries.length === 0) {
104
71
  console.error(`No skills found in ${repo} (no directories with SKILL.md)`);
@@ -107,206 +74,28 @@ if (skillEntries.length === 0) {
107
74
 
108
75
  // ── Classify skills ──────────────────────────────────────────────────
109
76
 
110
- type SkillEntry = { name: string; prefix: string; treeSHA: string; exists: boolean };
111
- const skills: SkillEntry[] = skillEntries.map(({ name, prefix, treeSHA }) => ({
77
+ const skills: AddSkillEntry[] = skillEntries.map(({ name, prefix, treeSHA }) => ({
112
78
  name,
113
79
  prefix,
114
80
  treeSHA,
115
81
  exists: skillExists(name),
116
82
  }));
117
83
 
118
- const addableCount = skills.filter((s) => !s.exists).length;
119
- if (addableCount === 0) {
84
+ const addable = skills.filter((s) => !s.exists);
85
+ if (addable.length === 0) {
120
86
  console.log("All skills already exist in your catalog.");
121
87
  process.exit(0);
122
88
  }
123
89
 
124
- // ── State ────────────────────────────────────────────────────────────
125
-
126
- let cursor = 0;
127
- const checked = new Set<number>();
128
- let statusTimeout: ReturnType<typeof setTimeout> | null = null;
129
- let exitResolve: () => void;
130
-
131
- for (let i = 0; i < skills.length; i++) {
132
- if (!skills[i]!.exists) { cursor = i; break; }
133
- }
134
-
135
- // ── Build TUI ────────────────────────────────────────────────────────
136
-
137
- const renderer = await createCliRenderer({ exitOnCtrlC: true });
138
-
139
- const outer = new BoxRenderable(renderer, {
140
- id: "outer",
141
- width: "100%",
142
- height: "100%",
143
- flexDirection: "column",
144
- backgroundColor: C.bg,
145
- });
146
-
147
- const header = new TextRenderable(renderer, {
148
- id: "header",
149
- content: ` Add skills from ${repo}`,
150
- fg: C.title,
151
- attributes: TextAttributes.BOLD,
152
- height: 1,
153
- });
154
-
155
- const sep = new TextRenderable(renderer, {
156
- id: "sep",
157
- content: "\u2500".repeat(60),
158
- fg: C.border,
159
- height: 1,
160
- });
161
-
162
- const scrollBox = new ScrollBoxRenderable(renderer, {
163
- id: "skill-list",
164
- flexGrow: 1,
165
- width: "100%",
166
- });
167
-
168
- type RowRefs = {
169
- row: BoxRenderable;
170
- checkText: TextRenderable;
171
- nameText: TextRenderable;
172
- };
173
- const rows: RowRefs[] = [];
174
-
175
- for (let i = 0; i < skills.length; i++) {
176
- const skill = skills[i]!;
177
-
178
- const row = new BoxRenderable(renderer, {
179
- id: `row-${i}`,
180
- flexDirection: "row",
181
- height: 1,
182
- width: "100%",
183
- paddingLeft: 1,
184
- backgroundColor: i % 2 === 0 ? C.rowBg : C.rowAltBg,
185
- });
186
-
187
- const checkText = new TextRenderable(renderer, {
188
- id: `check-${i}`,
189
- content: skill.exists ? "[*]" : "[ ]",
190
- fg: skill.exists ? C.fgDim : C.unchecked,
191
- width: 4,
192
- });
193
-
194
- const label = skill.exists ? `${skill.name} (exists)` : skill.name;
195
- const nameText = new TextRenderable(renderer, {
196
- id: `name-${i}`,
197
- content: ` ${label}`,
198
- fg: skill.exists ? C.fgDim : C.fg,
199
- width: 40,
200
- });
201
-
202
- row.add(checkText);
203
- row.add(nameText);
204
- scrollBox.add(row);
205
- rows.push({ row, checkText, nameText });
206
- }
207
-
208
- const footerSep = new TextRenderable(renderer, {
209
- id: "footer-sep",
210
- content: "\u2500".repeat(60),
211
- fg: C.border,
212
- height: 1,
213
- });
214
-
215
- const footer = new TextRenderable(renderer, {
216
- id: "footer",
217
- content: " j/k move space toggle a all enter confirm q cancel",
218
- fg: C.footer,
219
- height: 1,
220
- });
221
-
222
- const statusLine = new TextRenderable(renderer, {
223
- id: "status",
224
- content: "",
225
- fg: C.statusOk,
226
- height: 1,
227
- });
228
-
229
- outer.add(header);
230
- outer.add(sep);
231
- outer.add(scrollBox);
232
- outer.add(footerSep);
233
- outer.add(footer);
234
- outer.add(statusLine);
235
- renderer.root.add(outer);
236
-
237
- // ── Display helpers ──────────────────────────────────────────────────
238
-
239
- function updateRow(i: number) {
240
- const skill = skills[i]!;
241
- const r = rows[i]!;
242
- const isCursor = cursor === i;
243
-
244
- const baseBg = i % 2 === 0 ? C.rowBg : C.rowAltBg;
245
- r.row.backgroundColor = isCursor ? C.cursorBg : baseBg;
246
-
247
- if (skill.exists) {
248
- r.checkText.content = "[*]";
249
- r.checkText.fg = C.fgDim;
250
- const pointer = isCursor ? "\u25b8" : " ";
251
- r.nameText.content = `${pointer} ${skill.name} (exists)`;
252
- r.nameText.fg = C.fgDim;
253
- r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
254
- } else {
255
- const isChecked = checked.has(i);
256
- r.checkText.content = isChecked ? "[x]" : "[ ]";
257
- r.checkText.fg = isCursor ? C.accent : (isChecked ? C.checked : C.unchecked);
258
- const pointer = isCursor ? "\u25b8" : " ";
259
- r.nameText.content = `${pointer} ${skill.name}`;
260
- r.nameText.fg = isCursor ? "#ffffff" : C.fg;
261
- r.nameText.attributes = isCursor ? TextAttributes.BOLD : TextAttributes.NONE;
262
- }
263
- }
264
-
265
- function setStatus(msg: string, color: string) {
266
- statusLine.content = ` ${msg}`;
267
- statusLine.fg = color;
268
- if (statusTimeout) clearTimeout(statusTimeout);
269
- statusTimeout = setTimeout(() => {
270
- statusLine.content = "";
271
- }, 3000);
272
- }
273
-
274
- function selectedCount(): number {
275
- return checked.size;
276
- }
277
-
278
- function updateHeader() {
279
- header.content = ` Add skills from ${repo} (${selectedCount()} selected)`;
280
- }
281
-
282
- function refreshAll() {
283
- for (let i = 0; i < skills.length; i++) updateRow(i);
284
- updateHeader();
285
- }
286
-
287
- function ensureVisible() {
288
- scrollBox.scrollTo(Math.max(0, cursor - 2));
289
- }
290
-
291
- refreshAll();
292
-
293
- // ── Confirm & download ───────────────────────────────────────────────
294
-
295
- async function confirmAndDownload() {
296
- const selected = [...checked].map((i) => skills[i]!);
297
- if (selected.length === 0) {
298
- setStatus("Nothing selected \u2014 use space to toggle", C.warning);
299
- return;
300
- }
301
-
302
- renderer.destroy();
90
+ // ── Download helper ─────────────────────────────────────────────────
303
91
 
92
+ async function downloadSelected(selected: AddSkillEntry[]) {
304
93
  mkdirSync(CATALOG, { recursive: true });
94
+ let added = 0;
305
95
 
306
96
  for (const skill of selected) {
307
97
  const skillDir = join(CATALOG, skill.name);
308
98
 
309
- // Remove broken remnant if present
310
99
  try {
311
100
  if (lstatSync(skillDir).isSymbolicLink()) {
312
101
  rmSync(skillDir, { force: true });
@@ -314,87 +103,60 @@ async function confirmAndDownload() {
314
103
  } catch {}
315
104
 
316
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
+ }
317
126
 
318
- process.stdout.write(` ${skill.name}...`);
319
-
320
- const fileCount = await downloadSkillFiles(
321
- repo!, branch, skill.prefix, skillDir, tree,
322
- );
127
+ console.log(`\nAdded ${added}/${selected.length} skill(s) to ~/.skl/catalog/`);
128
+ }
323
129
 
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);
130
+ // ── --all: skip TUI ─────────────────────────────────────────────────
333
131
 
334
- console.log(` done (${fileCount} files)`);
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)`);
335
136
  }
336
-
337
- console.log(`\nAdded ${selected.length} skill(s) to ~/.skl/catalog/`);
338
- exitResolve();
137
+ console.log(`Adding ${addable.length} skill(s)...`);
138
+ await downloadSelected(addable);
139
+ process.exit(0);
339
140
  }
340
141
 
341
- // ── Key handler ──────────────────────────────────────────────────────
142
+ // ── TUI ─────────────────────────────────────────────────────────────
342
143
 
343
- renderer.keyInput.on("keypress", (key: KeyEvent) => {
344
- const prevCursor = cursor;
144
+ let exitResolve: () => void;
345
145
 
346
- switch (key.name) {
347
- case "j":
348
- case "down":
349
- if (cursor < skills.length - 1) cursor++;
350
- break;
351
- case "k":
352
- case "up":
353
- if (cursor > 0) cursor--;
354
- break;
355
- case "space": {
356
- if (skills[cursor]!.exists) {
357
- setStatus(`${skills[cursor]!.name} already exists`, C.warning);
358
- break;
359
- }
360
- if (checked.has(cursor)) {
361
- checked.delete(cursor);
362
- } else {
363
- checked.add(cursor);
364
- }
365
- break;
366
- }
367
- case "a": {
368
- const addable = skills
369
- .map((s, i) => (!s.exists ? i : -1))
370
- .filter((i) => i >= 0);
371
- const allChecked = addable.every((i) => checked.has(i));
372
- if (allChecked) {
373
- for (const i of addable) checked.delete(i);
374
- } else {
375
- for (const i of addable) checked.add(i);
376
- }
377
- for (const i of addable) updateRow(i);
378
- updateHeader();
379
- ensureVisible();
380
- return;
381
- }
382
- case "return":
383
- confirmAndDownload();
384
- return;
385
- case "q":
386
- case "escape":
387
- renderer.destroy();
388
- exitResolve();
389
- return;
390
- default:
391
- return;
392
- }
146
+ const renderer = await createCliRenderer({ exitOnCtrlC: true });
393
147
 
394
- if (prevCursor !== cursor) updateRow(prevCursor);
395
- updateRow(cursor);
396
- updateHeader();
397
- 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
+ },
398
160
  });
399
161
 
400
162
  await new Promise<void>((resolve) => {
package/index.ts CHANGED
@@ -4,7 +4,7 @@ import { createCliRenderer } from "@opentui/core";
4
4
  import { existsSync, rmSync, cpSync, mkdirSync, readdirSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
- import { getCatalogSkills, getLockEntry, removeFromLock, catalogDir } from "./lib.ts";
7
+ import { getCatalogSkills, removeFromLock, catalogDir, buildAddArgs, parseSkillsListOutput } from "./lib.ts";
8
8
  import { createTui, type ColId } from "./tui.ts";
9
9
 
10
10
  // @ts-ignore - bun supports JSON imports
@@ -16,6 +16,21 @@ if (arg === "-v" || arg === "--version") {
16
16
  console.log(`skl ${VERSION}`);
17
17
  process.exit(0);
18
18
  }
19
+ if (arg === "-h" || arg === "--help" || arg === "help") {
20
+ console.log(`skl ${VERSION} — TUI skill manager for Claude Code agents
21
+
22
+ Usage:
23
+ skl Open the interactive TUI
24
+ skl add <repo> Add skills from a GitHub repo (interactive)
25
+ skl add <repo> --all Add all skills from a repo (non-interactive)
26
+ skl update Update all remote skills
27
+ skl help Show this help message
28
+
29
+ Options:
30
+ -h, --help Show this help message
31
+ -v, --version Show version`);
32
+ process.exit(0);
33
+ }
19
34
 
20
35
  // ── Subcommand routing ───────────────────────────────────────────────
21
36
  if (arg === "add") {
@@ -59,10 +74,7 @@ async function listInstalled(global: boolean): Promise<Set<string>> {
59
74
  const out = await new Response(proc.stdout).text();
60
75
  const code = await proc.exited;
61
76
  if (code !== 0) return new Set();
62
- const data = JSON.parse(out.trim());
63
- if (Array.isArray(data)) {
64
- return new Set(data.map((s: { name: string }) => s.name));
65
- }
77
+ return new Set(parseSkillsListOutput(out.trim()));
66
78
  } catch {}
67
79
  return new Set();
68
80
  }
@@ -74,20 +86,6 @@ const [globalInstalled, localInstalled] = await Promise.all([
74
86
  ]);
75
87
  console.log(" done");
76
88
 
77
- // ── Build add/remove args ────────────────────────────────────────────
78
-
79
- function buildAddArgs(name: string, global: boolean): string[] {
80
- const lockEntry = getLockEntry(name);
81
- if (lockEntry) {
82
- const args = ["add", lockEntry.sourceUrl, "--skill", name, "-y"];
83
- if (global) args.push("-g");
84
- return args;
85
- }
86
- const args = ["add", join(CATALOG, name), "-y"];
87
- if (global) args.push("-g");
88
- return args;
89
- }
90
-
91
89
  // ── Create TUI ──────────────────────────────────────────────────────
92
90
 
93
91
  const renderer = await createCliRenderer({ exitOnCtrlC: true });
@@ -101,7 +99,7 @@ const tui = createTui(renderer, {
101
99
  async onToggle(col: ColId, name: string, enable: boolean) {
102
100
  const isGlobal = col === "global";
103
101
  const args = enable
104
- ? buildAddArgs(name, isGlobal)
102
+ ? buildAddArgs(CATALOG, name, isGlobal)
105
103
  : ["remove", name, "-y", ...(isGlobal ? ["-g"] : [])];
106
104
  const proc = Bun.spawn(["npx", "-y", "skills", ...args], {
107
105
  stdout: "pipe",