@dealdeploy/skl 1.2.0 → 1.4.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/index.ts CHANGED
@@ -6,14 +6,21 @@ import { join } from "path";
6
6
  import { homedir } from "os";
7
7
  import { getCatalogSkills, removeFromLock, catalogDir, buildAddArgs, readLock, detectProjectAgents } from "./lib.ts";
8
8
  import { createTui, type ColId } from "./tui.ts";
9
+ import { checkForUpdate } from "./update-check.ts";
9
10
 
10
11
  // @ts-ignore - bun supports JSON imports
11
12
  const { version: VERSION } = await import("./package.json");
12
13
 
14
+ // Fire update check in background (non-blocking)
15
+ const updateMsg = checkForUpdate(VERSION);
16
+
13
17
  // ── Flags ────────────────────────────────────────────────────────────
18
+ const debug = process.argv.includes("--debug");
14
19
  const arg = process.argv[2];
15
20
  if (arg === "-v" || arg === "--version") {
16
21
  console.log(`skl ${VERSION}`);
22
+ const msg = await updateMsg;
23
+ if (msg) console.log(msg);
17
24
  process.exit(0);
18
25
  }
19
26
  if (arg === "-h" || arg === "--help" || arg === "help") {
@@ -30,21 +37,30 @@ Usage:
30
37
 
31
38
  Options:
32
39
  -h, --help Show this help message
33
- -v, --version Show version`);
40
+ -v, --version Show version
41
+ --debug Show full error output on failures`);
34
42
  process.exit(0);
35
43
  }
36
44
 
37
45
  // ── Subcommand routing ───────────────────────────────────────────────
46
+ async function printUpdateMsg() {
47
+ const msg = await updateMsg;
48
+ if (msg) console.log(`\n${msg}`);
49
+ }
50
+
38
51
  if (arg === "add") {
39
52
  await import("./add.ts");
53
+ await printUpdateMsg();
40
54
  process.exit(0);
41
55
  }
42
56
  if (arg === "update") {
43
57
  await import("./update.ts");
58
+ await printUpdateMsg();
44
59
  process.exit(0);
45
60
  }
46
61
  if (arg === "migrate") {
47
62
  await import("./migrate.ts");
63
+ await printUpdateMsg();
48
64
  process.exit(0);
49
65
  }
50
66
 
@@ -101,8 +117,16 @@ const tui = createTui(renderer, {
101
117
  stdout: "pipe",
102
118
  stderr: "pipe",
103
119
  });
104
- const code = await proc.exited;
105
- return code === 0;
120
+ const [code, stdout, stderr] = await Promise.all([
121
+ proc.exited,
122
+ new Response(proc.stdout).text(),
123
+ new Response(proc.stderr).text(),
124
+ ]);
125
+ if (code === 0) return true;
126
+ if (debug) {
127
+ return (stderr || stdout).trim() || `exit code ${code}`;
128
+ }
129
+ return false;
106
130
  },
107
131
 
108
132
  async onDelete(name: string) {
@@ -141,8 +165,9 @@ const tui = createTui(renderer, {
141
165
  process.exit(0);
142
166
  },
143
167
 
144
- onQuit() {
168
+ async onQuit() {
145
169
  renderer.destroy();
170
+ await printUpdateMsg();
146
171
  process.exit(0);
147
172
  },
148
173
  });
package/lib.test.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  findSkillEntries,
17
17
  parseRepoArg,
18
18
  buildAddArgs,
19
+ readSkillFrontmatterName,
19
20
  detectProjectAgents,
20
21
  parseSkillsListOutput,
21
22
  planUpdates,
@@ -326,6 +327,67 @@ describe("buildAddArgs", () => {
326
327
  const args = buildAddArgs("/my/catalog", "custom-skill", true);
327
328
  expect(args).toEqual(["add", "/my/catalog/custom-skill", "-y", "-g"]);
328
329
  });
330
+
331
+ test("uses SKILL.md frontmatter name for --skill flag when it differs from dir name", () => {
332
+ const catalog = catalogDir();
333
+ mkdirSync(join(catalog, "code-review"), { recursive: true });
334
+ writeFileSync(
335
+ join(catalog, "code-review", "SKILL.md"),
336
+ "---\nname: code-review:code-review\ndescription: Review PRs\n---\n# Code Review"
337
+ );
338
+
339
+ const entry: LockEntry = {
340
+ source: "owner/repo",
341
+ sourceUrl: "https://github.com/owner/repo",
342
+ skillPath: "skills/code-review",
343
+ treeSHA: "abc",
344
+ addedAt: "2026-01-01",
345
+ };
346
+ addToLock("code-review", entry);
347
+
348
+ const args = buildAddArgs(catalog, "code-review", true);
349
+ expect(args).toContain("--skill");
350
+ expect(args).toContain("code-review:code-review");
351
+ expect(args).not.toContain("--agent");
352
+ expect(args).toContain("-g");
353
+ });
354
+ });
355
+
356
+ // ── readSkillFrontmatterName ────────────────────────────────────────
357
+
358
+ describe("readSkillFrontmatterName", () => {
359
+ test("reads name from valid frontmatter", () => {
360
+ const catalog = catalogDir();
361
+ mkdirSync(join(catalog, "my-skill"), { recursive: true });
362
+ writeFileSync(
363
+ join(catalog, "my-skill", "SKILL.md"),
364
+ "---\nname: my-skill:variant\ndescription: test\n---\n# Content"
365
+ );
366
+ expect(readSkillFrontmatterName(catalog, "my-skill")).toBe("my-skill:variant");
367
+ });
368
+
369
+ test("returns null when no frontmatter", () => {
370
+ const catalog = catalogDir();
371
+ mkdirSync(join(catalog, "no-fm"), { recursive: true });
372
+ writeFileSync(join(catalog, "no-fm", "SKILL.md"), "# Just markdown");
373
+ expect(readSkillFrontmatterName(catalog, "no-fm")).toBeNull();
374
+ });
375
+
376
+ test("returns null when SKILL.md missing", () => {
377
+ const catalog = catalogDir();
378
+ mkdirSync(join(catalog, "empty"), { recursive: true });
379
+ expect(readSkillFrontmatterName(catalog, "empty")).toBeNull();
380
+ });
381
+
382
+ test("returns null when name field missing from frontmatter", () => {
383
+ const catalog = catalogDir();
384
+ mkdirSync(join(catalog, "no-name"), { recursive: true });
385
+ writeFileSync(
386
+ join(catalog, "no-name", "SKILL.md"),
387
+ "---\ndescription: has no name\n---\n# Content"
388
+ );
389
+ expect(readSkillFrontmatterName(catalog, "no-name")).toBeNull();
390
+ });
329
391
  });
330
392
 
331
393
  // ── detectProjectAgents ─────────────────────────────────────────────
package/lib.ts CHANGED
@@ -160,6 +160,19 @@ export function detectProjectAgents(cwd: string): string[] {
160
160
  return agents;
161
161
  }
162
162
 
163
+ /** Read the frontmatter `name` from a skill's SKILL.md in the catalog. */
164
+ export function readSkillFrontmatterName(catalogPath: string, dirName: string): string | null {
165
+ try {
166
+ const content = readFileSync(join(catalogPath, dirName, "SKILL.md"), "utf-8");
167
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
168
+ if (!match) return null;
169
+ const nameMatch = match[1]!.match(/^name:\s*(.+)$/m);
170
+ return nameMatch ? nameMatch[1]!.trim() : null;
171
+ } catch {
172
+ return null;
173
+ }
174
+ }
175
+
163
176
  /** Build npx skills add args for a skill. */
164
177
  export function buildAddArgs(
165
178
  catalogPath: string,
@@ -172,7 +185,8 @@ export function buildAddArgs(
172
185
  ? ["--agent", ...projectAgents]
173
186
  : [];
174
187
  if (lockEntry) {
175
- const args = ["add", lockEntry.sourceUrl, "--skill", name, "-y", ...agentArgs];
188
+ const skillName = readSkillFrontmatterName(catalogPath, name) ?? name;
189
+ const args = ["add", lockEntry.sourceUrl, "--skill", skillName, "-y", ...agentArgs];
176
190
  if (isGlobal) args.push("-g");
177
191
  return args;
178
192
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dealdeploy/skl",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "TUI skill manager for Claude Code agents",
5
5
  "module": "index.ts",
6
6
  "bin": {
package/tui.ts CHANGED
@@ -18,8 +18,8 @@ export type TuiDeps = {
18
18
  globalInstalled: Set<string>;
19
19
  localInstalled: Set<string>;
20
20
  catalogPath: string;
21
- /** Called when a skill toggle is requested. Return true on success. */
22
- onToggle: (col: ColId, name: string, enable: boolean) => Promise<boolean>;
21
+ /** Called when a skill toggle is requested. Return true on success, false or error string on failure. */
22
+ onToggle: (col: ColId, name: string, enable: boolean) => Promise<boolean | string>;
23
23
  /** Called when a skill delete is requested. */
24
24
  onDelete: (name: string) => Promise<void>;
25
25
  /** Called when edit is requested. Return false to stay in TUI. */
@@ -239,6 +239,14 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
239
239
  const rows: RowRefs[] = new Array(allSkills.length);
240
240
 
241
241
  scrollBox.add(searchRow);
242
+ scrollBox.add(
243
+ new BoxRenderable(renderer, {
244
+ id: "spacer-search",
245
+ height: 1,
246
+ width: "100%",
247
+ backgroundColor: C.rowBg,
248
+ })
249
+ );
242
250
 
243
251
  for (let di = 0; di < displayItems.length; di++) {
244
252
  const item = displayItems[di]!;
@@ -490,7 +498,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
490
498
  if (rows[item.skillIndex]!.row.visible) visibleBefore++;
491
499
  }
492
500
  }
493
- scrollBox.scrollTo(Math.max(0, visibleBefore + 1 - 2));
501
+ scrollBox.scrollTo(Math.max(0, visibleBefore + 2 - 2));
494
502
  }
495
503
  }
496
504
 
@@ -505,8 +513,8 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
505
513
  refreshAll();
506
514
 
507
515
  try {
508
- const ok = await deps.onToggle(col, name, !enabled);
509
- if (ok) {
516
+ const result = await deps.onToggle(col, name, !enabled);
517
+ if (result === true) {
510
518
  const set = col === "global" ? globalInstalled : localInstalled;
511
519
  if (enabled) {
512
520
  set.delete(name);
@@ -516,7 +524,8 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
516
524
  setStatus(`Added ${name}`, C.statusOk);
517
525
  }
518
526
  } else {
519
- setStatus(`Failed to ${enabled ? "remove" : "add"} ${name}`, C.statusErr);
527
+ const detail = typeof result === "string" ? `: ${result}` : "";
528
+ setStatus(`Failed to ${enabled ? "remove" : "add"} ${name}${detail}`, C.statusErr);
520
529
  }
521
530
  } finally {
522
531
  pendingToggles.delete(key);
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
2
+ import { join } from "path";
3
+ import { homedir } from "os";
4
+
5
+ const CACHE_PATH = join(homedir(), ".skl", "update-check.json");
6
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 1 day
7
+ const PKG_NAME = "@dealdeploy/skl";
8
+
9
+ type Cache = { latest: string; checkedAt: number };
10
+
11
+ function readCache(): Cache | null {
12
+ try {
13
+ return JSON.parse(readFileSync(CACHE_PATH, "utf-8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function writeCache(cache: Cache): void {
20
+ mkdirSync(join(homedir(), ".skl"), { recursive: true });
21
+ writeFileSync(CACHE_PATH, JSON.stringify(cache));
22
+ }
23
+
24
+ /**
25
+ * Check npm for the latest version. Non-blocking — fire and forget.
26
+ * Returns a message string if an update is available, or null.
27
+ */
28
+ export async function checkForUpdate(currentVersion: string): Promise<string | null> {
29
+ const cache = readCache();
30
+ const now = Date.now();
31
+
32
+ // Use cache if fresh
33
+ if (cache && now - cache.checkedAt < CHECK_INTERVAL_MS) {
34
+ return compareVersions(currentVersion, cache.latest);
35
+ }
36
+
37
+ // Fetch from registry
38
+ try {
39
+ const resp = await fetch(`https://registry.npmjs.org/${PKG_NAME}/latest`, {
40
+ signal: AbortSignal.timeout(3000),
41
+ });
42
+ if (!resp.ok) return null;
43
+ const data = (await resp.json()) as { version: string };
44
+ writeCache({ latest: data.version, checkedAt: now });
45
+ return compareVersions(currentVersion, data.version);
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function compareVersions(current: string, latest: string): string | null {
52
+ if (current === latest) return null;
53
+ const [cMaj, cMin, cPat] = current.split(".").map(Number);
54
+ const [lMaj, lMin, lPat] = latest.split(".").map(Number);
55
+ if (lMaj > cMaj || lMin > cMin || lPat > cPat) {
56
+ return `Update available: ${current} → ${latest} (run: bun install -g ${PKG_NAME})`;
57
+ }
58
+ return null;
59
+ }