@dealdeploy/skl 1.1.0 → 1.2.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
@@ -16,11 +16,12 @@ const CATALOG = catalogDir();
16
16
 
17
17
  const allFlag = process.argv.includes("--all");
18
18
  const repoArg = process.argv.slice(3).find((a) => !a.startsWith("-")) ?? "";
19
- const repo = parseRepoArg(repoArg);
20
- if (!repo) {
19
+ const repoParsed = parseRepoArg(repoArg);
20
+ if (!repoParsed) {
21
21
  console.error("Usage: skl add owner/repo [--all]");
22
22
  process.exit(1);
23
23
  }
24
+ const repo: string = repoParsed;
24
25
 
25
26
  // ── Helpers ──────────────────────────────────────────────────────────
26
27
 
package/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { createCliRenderer } from "@opentui/core";
4
- import { existsSync, rmSync, cpSync, mkdirSync, readdirSync } from "fs";
4
+ import { existsSync, rmSync, mkdirSync, readdirSync, statSync } from "fs";
5
5
  import { join } from "path";
6
6
  import { homedir } from "os";
7
- import { getCatalogSkills, removeFromLock, catalogDir, buildAddArgs, parseSkillsListOutput } from "./lib.ts";
7
+ import { getCatalogSkills, removeFromLock, catalogDir, buildAddArgs, readLock, detectProjectAgents } from "./lib.ts";
8
8
  import { createTui, type ColId } from "./tui.ts";
9
9
 
10
10
  // @ts-ignore - bun supports JSON imports
@@ -24,6 +24,8 @@ Usage:
24
24
  skl add <repo> Add skills from a GitHub repo (interactive)
25
25
  skl add <repo> --all Add all skills from a repo (non-interactive)
26
26
  skl update Update all remote skills
27
+ skl migrate Migrate old symlinks to use the catalog
28
+ skl migrate -l Migrate local project symlinks
27
29
  skl help Show this help message
28
30
 
29
31
  Options:
@@ -41,66 +43,60 @@ if (arg === "update") {
41
43
  await import("./update.ts");
42
44
  process.exit(0);
43
45
  }
46
+ if (arg === "migrate") {
47
+ await import("./migrate.ts");
48
+ process.exit(0);
49
+ }
44
50
 
45
51
  try {
46
52
 
47
- // ── Migration: ~/dotfiles/skills → ~/.skl/catalog ───────────────────
48
- const OLD_LIBRARY = join(homedir(), "dotfiles/skills");
49
53
  const CATALOG = catalogDir();
50
-
51
- if (!existsSync(CATALOG) && existsSync(OLD_LIBRARY)) {
52
- console.log("Migrating skills from ~/dotfiles/skills to ~/.skl/catalog...");
53
- mkdirSync(CATALOG, { recursive: true });
54
- for (const name of readdirSync(OLD_LIBRARY)) {
55
- const src = join(OLD_LIBRARY, name);
56
- const dest = join(CATALOG, name);
57
- try {
58
- cpSync(src, dest, { recursive: true });
59
- console.log(` ${name}`);
60
- } catch {}
61
- }
62
- console.log("Done. You can remove ~/dotfiles/skills when ready.\n");
63
- }
64
-
65
54
  mkdirSync(CATALOG, { recursive: true });
66
55
 
67
- // ── Load installed state from skills CLI ─────────────────────────────
56
+ // ── Read installed skills directly from filesystem ──────────────────
68
57
 
69
- async function listInstalled(global: boolean): Promise<Set<string>> {
70
- const args = ["npx", "-y", "skills", "list", "--json"];
71
- if (global) args.push("-g");
58
+ function readInstalledSkills(dir: string): Set<string> {
72
59
  try {
73
- const proc = Bun.spawn(args, { stdout: "pipe", stderr: "pipe" });
74
- const out = await new Response(proc.stdout).text();
75
- const code = await proc.exited;
76
- if (code !== 0) return new Set();
77
- return new Set(parseSkillsListOutput(out.trim()));
78
- } catch {}
79
- return new Set();
60
+ return new Set(
61
+ readdirSync(dir).filter((name) => {
62
+ try { return statSync(join(dir, name)).isDirectory(); } catch { return false; }
63
+ })
64
+ );
65
+ } catch {
66
+ return new Set();
67
+ }
80
68
  }
81
69
 
82
- process.stdout.write("Loading installed skills...");
83
- const [globalInstalled, localInstalled] = await Promise.all([
84
- listInstalled(true),
85
- listInstalled(false),
86
- ]);
87
- console.log(" done");
70
+ const globalInstalled = readInstalledSkills(join(homedir(), ".claude/skills"));
71
+ const localInstalled = readInstalledSkills(join(process.cwd(), ".claude/skills"));
72
+
73
+ // Always install to .agents/skills (universal), plus any other agent dirs found in the project.
74
+ const projectAgents = [...new Set(["universal", ...detectProjectAgents(process.cwd())])];
88
75
 
89
76
  // ── Create TUI ──────────────────────────────────────────────────────
90
77
 
91
78
  const renderer = await createCliRenderer({ exitOnCtrlC: true });
92
79
 
80
+ const allSkills = getCatalogSkills();
81
+ const lock = readLock();
82
+ const skillRepos = new Map<string, string | null>();
83
+ for (const name of allSkills) {
84
+ skillRepos.set(name, lock.skills[name]?.source || null);
85
+ }
86
+
93
87
  const tui = createTui(renderer, {
94
- allSkills: getCatalogSkills(),
88
+ allSkills,
89
+ skillRepos,
95
90
  globalInstalled,
96
91
  localInstalled,
97
92
  catalogPath: CATALOG,
98
93
 
99
94
  async onToggle(col: ColId, name: string, enable: boolean) {
100
95
  const isGlobal = col === "global";
96
+ const agentArgs = isGlobal ? [] : ["--agent", ...projectAgents];
101
97
  const args = enable
102
- ? buildAddArgs(CATALOG, name, isGlobal)
103
- : ["remove", name, "-y", ...(isGlobal ? ["-g"] : [])];
98
+ ? buildAddArgs(CATALOG, name, isGlobal, projectAgents)
99
+ : ["remove", name, "-y", ...agentArgs, ...(isGlobal ? ["-g"] : [])];
104
100
  const proc = Bun.spawn(["npx", "-y", "skills", ...args], {
105
101
  stdout: "pipe",
106
102
  stderr: "pipe",
@@ -120,7 +116,7 @@ const tui = createTui(renderer, {
120
116
  }
121
117
  if (localInstalled.has(name)) {
122
118
  procs.push(
123
- Bun.spawn(["npx", "-y", "skills", "remove", name, "-y"], {
119
+ Bun.spawn(["npx", "-y", "skills", "remove", name, "-y", "--agent", ...projectAgents], {
124
120
  stdout: "pipe", stderr: "pipe",
125
121
  })
126
122
  );
package/lib.test.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  findSkillEntries,
17
17
  parseRepoArg,
18
18
  buildAddArgs,
19
+ detectProjectAgents,
19
20
  parseSkillsListOutput,
20
21
  planUpdates,
21
22
  groupByRepo,
@@ -287,7 +288,7 @@ describe("parseRepoArg", () => {
287
288
  // ── buildAddArgs ────────────────────────────────────────────────────
288
289
 
289
290
  describe("buildAddArgs", () => {
290
- test("uses lock entry sourceUrl for remote skill", () => {
291
+ test("uses lock entry sourceUrl for remote skill with project agents", () => {
291
292
  const entry: LockEntry = {
292
293
  source: "owner/repo",
293
294
  sourceUrl: "https://github.com/owner/repo",
@@ -297,11 +298,11 @@ describe("buildAddArgs", () => {
297
298
  };
298
299
  addToLock("myskill", entry);
299
300
 
300
- const args = buildAddArgs("/catalog", "myskill", false);
301
- expect(args).toEqual(["add", "https://github.com/owner/repo", "--skill", "myskill", "-y"]);
301
+ const args = buildAddArgs("/catalog", "myskill", false, ["claude-code", "roo"]);
302
+ expect(args).toEqual(["add", "https://github.com/owner/repo", "--skill", "myskill", "-y", "--agent", "claude-code", "roo"]);
302
303
  });
303
304
 
304
- test("adds -g flag for global remote skill", () => {
305
+ test("global install does not pass --agent", () => {
305
306
  const entry: LockEntry = {
306
307
  source: "owner/repo",
307
308
  sourceUrl: "https://github.com/owner/repo",
@@ -311,14 +312,14 @@ describe("buildAddArgs", () => {
311
312
  };
312
313
  addToLock("myskill", entry);
313
314
 
314
- const args = buildAddArgs("/catalog", "myskill", true);
315
+ const args = buildAddArgs("/catalog", "myskill", true, ["claude-code"]);
315
316
  expect(args).toContain("-g");
317
+ expect(args).not.toContain("--agent");
316
318
  });
317
319
 
318
- test("uses catalog path for hand-authored skill", () => {
319
- // No lock entry for this skill
320
- const args = buildAddArgs("/my/catalog", "custom-skill", false);
321
- expect(args).toEqual(["add", "/my/catalog/custom-skill", "-y"]);
320
+ test("uses catalog path for hand-authored skill with project agents", () => {
321
+ const args = buildAddArgs("/my/catalog", "custom-skill", false, ["claude-code"]);
322
+ expect(args).toEqual(["add", "/my/catalog/custom-skill", "-y", "--agent", "claude-code"]);
322
323
  });
323
324
 
324
325
  test("adds -g flag for global hand-authored skill", () => {
@@ -327,6 +328,45 @@ describe("buildAddArgs", () => {
327
328
  });
328
329
  });
329
330
 
331
+ // ── detectProjectAgents ─────────────────────────────────────────────
332
+
333
+ describe("detectProjectAgents", () => {
334
+ test("detects .claude directory", () => {
335
+ const dir = mkdtempSync(join(tmpdir(), "skl-detect-"));
336
+ mkdirSync(join(dir, ".claude"));
337
+ const agents = detectProjectAgents(dir);
338
+ expect(agents).toEqual(["claude-code"]);
339
+ rmSync(dir, { recursive: true, force: true });
340
+ });
341
+
342
+ test("detects multiple agent directories", () => {
343
+ const dir = mkdtempSync(join(tmpdir(), "skl-detect-"));
344
+ mkdirSync(join(dir, ".claude"));
345
+ mkdirSync(join(dir, ".roo"));
346
+ const agents = detectProjectAgents(dir);
347
+ expect(agents).toContain("claude-code");
348
+ expect(agents).toContain("roo");
349
+ rmSync(dir, { recursive: true, force: true });
350
+ });
351
+
352
+ test("returns empty array when no agent dirs exist (caller decides fallback)", () => {
353
+ const dir = mkdtempSync(join(tmpdir(), "skl-detect-"));
354
+ const agents = detectProjectAgents(dir);
355
+ expect(agents).toEqual([]);
356
+ rmSync(dir, { recursive: true, force: true });
357
+ });
358
+
359
+ test("detects .agents dir and maps to universal agents", () => {
360
+ const dir = mkdtempSync(join(tmpdir(), "skl-detect-"));
361
+ mkdirSync(join(dir, ".agents"));
362
+ const agents = detectProjectAgents(dir);
363
+ expect(agents).toContain("cursor");
364
+ expect(agents).toContain("codex");
365
+ expect(agents).toContain("opencode");
366
+ rmSync(dir, { recursive: true, force: true });
367
+ });
368
+ });
369
+
330
370
  // ── parseSkillsListOutput ───────────────────────────────────────────
331
371
 
332
372
  describe("parseSkillsListOutput", () => {
package/lib.ts CHANGED
@@ -112,19 +112,71 @@ export function parseRepoArg(input: string): string | null {
112
112
  return repo;
113
113
  }
114
114
 
115
+ // ── Agent detection ─────────────────────────────────────────────────
116
+ // Maps local project directories to skills CLI agent names.
117
+ // Synced from: vercel-labs/skills src/agents.ts
118
+ const DIR_TO_AGENTS: Record<string, string[]> = {
119
+ ".agents": ["amp", "cline", "codex", "cursor", "gemini-cli", "github-copilot", "kimi-cli", "opencode", "replit", "universal"],
120
+ ".agent": ["antigravity"],
121
+ ".augment": ["augment"],
122
+ ".claude": ["claude-code"],
123
+ ".codebuddy": ["codebuddy"],
124
+ ".commandcode": ["command-code"],
125
+ ".continue": ["continue"],
126
+ ".cortex": ["cortex"],
127
+ ".crush": ["crush"],
128
+ ".factory": ["droid"],
129
+ ".goose": ["goose"],
130
+ ".iflow": ["iflow-cli"],
131
+ ".junie": ["junie"],
132
+ ".kilocode": ["kilo"],
133
+ ".kiro": ["kiro-cli"],
134
+ ".kode": ["kode"],
135
+ ".mcpjam": ["mcpjam"],
136
+ ".mux": ["mux"],
137
+ ".neovate": ["neovate"],
138
+ ".openhands": ["openhands"],
139
+ ".pi": ["pi"],
140
+ ".pochi": ["pochi"],
141
+ ".qoder": ["qoder"],
142
+ ".qwen": ["qwen-code"],
143
+ ".roo": ["roo"],
144
+ ".trae": ["trae", "trae-cn"],
145
+ ".vibe": ["mistral-vibe"],
146
+ ".windsurf": ["windsurf"],
147
+ ".zencoder": ["zencoder"],
148
+ ".adal": ["adal"],
149
+ "skills": ["openclaw"],
150
+ };
151
+
152
+ /** Detect which agent directories exist in a project directory. */
153
+ export function detectProjectAgents(cwd: string): string[] {
154
+ const agents: string[] = [];
155
+ for (const [dir, names] of Object.entries(DIR_TO_AGENTS)) {
156
+ if (existsSync(join(cwd, dir))) {
157
+ agents.push(...names);
158
+ }
159
+ }
160
+ return agents;
161
+ }
162
+
115
163
  /** Build npx skills add args for a skill. */
116
164
  export function buildAddArgs(
117
165
  catalogPath: string,
118
166
  name: string,
119
167
  isGlobal: boolean,
168
+ projectAgents?: string[],
120
169
  ): string[] {
121
170
  const lockEntry = getLockEntry(name);
171
+ const agentArgs = !isGlobal && projectAgents
172
+ ? ["--agent", ...projectAgents]
173
+ : [];
122
174
  if (lockEntry) {
123
- const args = ["add", lockEntry.sourceUrl, "--skill", name, "-y"];
175
+ const args = ["add", lockEntry.sourceUrl, "--skill", name, "-y", ...agentArgs];
124
176
  if (isGlobal) args.push("-g");
125
177
  return args;
126
178
  }
127
- const args = ["add", join(catalogPath, name), "-y"];
179
+ const args = ["add", join(catalogPath, name), "-y", ...agentArgs];
128
180
  if (isGlobal) args.push("-g");
129
181
  return args;
130
182
  }
package/migrate.ts ADDED
@@ -0,0 +1,153 @@
1
+ import { readdirSync, lstatSync, readlinkSync, rmSync } from "fs";
2
+ import { join, resolve } from "path";
3
+ import { homedir } from "os";
4
+ import { catalogDir, buildAddArgs, getCatalogSkills, detectProjectAgents } from "./lib.ts";
5
+
6
+ // ── Parse flags ──────────────────────────────────────────────────────
7
+ const args = process.argv.slice(3);
8
+ const localMode = args.includes("-l") || args.includes("--local");
9
+ const dryRun = args.includes("--dry-run");
10
+
11
+ const CATALOG = catalogDir();
12
+ const catalogResolved = resolve(CATALOG);
13
+ const catalogSkills = new Set(getCatalogSkills());
14
+
15
+ // ── Determine directories ────────────────────────────────────────────
16
+ const home = homedir();
17
+ const dirsToScan = localMode
18
+ ? [join(process.cwd(), ".claude/skills"), join(process.cwd(), ".agents/skills")]
19
+ : [join(home, ".agents/skills"), join(home, ".claude/skills")];
20
+
21
+ const isGlobal = !localMode;
22
+ const projectAgents = localMode
23
+ ? [...new Set(["universal", ...detectProjectAgents(process.cwd())])]
24
+ : [];
25
+
26
+ // ── Find stale symlinks ─────────────────────────────────────────────
27
+ type StaleLink = { name: string; target: string; dir: string };
28
+
29
+ function findStaleSymlinks(dir: string): StaleLink[] {
30
+ const stale: StaleLink[] = [];
31
+ let entries: string[];
32
+ try {
33
+ entries = readdirSync(dir);
34
+ } catch {
35
+ return stale;
36
+ }
37
+ for (const name of entries) {
38
+ const full = join(dir, name);
39
+ try {
40
+ const stat = lstatSync(full);
41
+ if (!stat.isSymbolicLink()) continue;
42
+ const target = resolve(dir, readlinkSync(full));
43
+ if (!target.startsWith(catalogResolved + "/")) {
44
+ stale.push({ name, target, dir });
45
+ }
46
+ } catch {
47
+ continue;
48
+ }
49
+ }
50
+ return stale;
51
+ }
52
+
53
+ // ── Collect and deduplicate ──────────────────────────────────────────
54
+ const allStale: StaleLink[] = [];
55
+ for (const dir of dirsToScan) {
56
+ allStale.push(...findStaleSymlinks(dir));
57
+ }
58
+
59
+ // Deduplicate by skill name (same skill may appear in multiple dirs)
60
+ const byName = new Map<string, StaleLink[]>();
61
+ for (const link of allStale) {
62
+ const list = byName.get(link.name) ?? [];
63
+ list.push(link);
64
+ byName.set(link.name, list);
65
+ }
66
+
67
+ if (byName.size === 0) {
68
+ console.log("No stale symlinks found. Everything is using the catalog.");
69
+ process.exit(0);
70
+ }
71
+
72
+ const inCatalog: string[] = [];
73
+ const notInCatalog: string[] = [];
74
+ for (const name of byName.keys()) {
75
+ if (catalogSkills.has(name)) {
76
+ inCatalog.push(name);
77
+ } else {
78
+ notInCatalog.push(name);
79
+ }
80
+ }
81
+
82
+ console.log(
83
+ `Found ${byName.size} stale symlink(s): ${inCatalog.length} in catalog, ${notInCatalog.length} missing from catalog`
84
+ );
85
+
86
+ if (dryRun) {
87
+ console.log("\nDry run — no changes will be made:\n");
88
+ }
89
+
90
+ // ── Migrate skills that are in the catalog ───────────────────────────
91
+ let migrated = 0;
92
+ let failed = 0;
93
+
94
+ for (const name of inCatalog.sort()) {
95
+ const links = byName.get(name)!;
96
+ if (dryRun) {
97
+ console.log(` ${name}: would migrate (${links.map((l) => l.dir).join(", ")})`);
98
+ continue;
99
+ }
100
+
101
+ process.stdout.write(` ${name}...`);
102
+
103
+ // Remove all old symlinks for this skill
104
+ for (const link of links) {
105
+ rmSync(join(link.dir, link.name), { force: true });
106
+ }
107
+
108
+ // Reinstall from catalog (try lock-based source first, fall back to local catalog path)
109
+ const addArgs = buildAddArgs(CATALOG, name, isGlobal, isGlobal ? undefined : projectAgents);
110
+ let proc = Bun.spawn(["npx", "-y", "skills", ...addArgs], {
111
+ stdout: "pipe",
112
+ stderr: "pipe",
113
+ });
114
+ let code = await proc.exited;
115
+
116
+ // If remote source failed, fall back to local catalog path
117
+ if (code !== 0) {
118
+ const localArgs = ["add", join(CATALOG, name), "-y", ...(isGlobal ? ["-g"] : ["--agent", ...projectAgents])];
119
+ proc = Bun.spawn(["npx", "-y", "skills", ...localArgs], {
120
+ stdout: "pipe",
121
+ stderr: "pipe",
122
+ });
123
+ code = await proc.exited;
124
+ }
125
+
126
+ if (code === 0) {
127
+ console.log(" migrated");
128
+ migrated++;
129
+ } else {
130
+ const stderr = await new Response(proc.stderr).text();
131
+ console.log(` failed (exit ${code})`);
132
+ if (stderr.trim()) console.log(` ${stderr.trim()}`);
133
+ failed++;
134
+ }
135
+ }
136
+
137
+ // ── Warn about skills not in catalog ─────────────────────────────────
138
+ if (notInCatalog.length > 0) {
139
+ console.log(`\nNot in catalog (run \`skl add <repo>\` first):`);
140
+ for (const name of notInCatalog.sort()) {
141
+ const links = byName.get(name)!;
142
+ console.log(` ${name} → ${links[0].target}`);
143
+ }
144
+ }
145
+
146
+ // ── Summary ──────────────────────────────────────────────────────────
147
+ if (!dryRun) {
148
+ const parts: string[] = [];
149
+ if (migrated > 0) parts.push(`Migrated ${migrated} skill(s)`);
150
+ if (failed > 0) parts.push(`${failed} failed`);
151
+ if (notInCatalog.length > 0) parts.push(`${notInCatalog.length} need \`skl add\` first`);
152
+ console.log(`\n${parts.join(", ")}`);
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dealdeploy/skl",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "TUI skill manager for Claude Code agents",
5
5
  "module": "index.ts",
6
6
  "bin": {
package/tsconfig.json CHANGED
@@ -25,5 +25,6 @@
25
25
  "noUnusedLocals": false,
26
26
  "noUnusedParameters": false,
27
27
  "noPropertyAccessFromIndexSignature": false
28
- }
28
+ },
29
+ "exclude": ["opensrc", "node_modules"]
29
30
  }
package/tui.test.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { test, expect, beforeEach, afterEach } from "bun:test";
2
2
  import { createTestRenderer } from "@opentui/core/testing";
3
- import { createTui, type ColId } from "./tui.ts";
3
+ import { createTui, type ColId, type DisplayItem } from "./tui.ts";
4
4
 
5
5
  const WIDTH = 80;
6
6
  const HEIGHT = 20;
@@ -12,8 +12,10 @@ function setup(opts?: {
12
12
  skills?: string[];
13
13
  globalInstalled?: string[];
14
14
  localInstalled?: string[];
15
+ skillRepos?: Map<string, string | null>;
15
16
  }) {
16
17
  const skills = opts?.skills ?? ["alpha", "beta", "gamma"];
18
+ const skillRepos = opts?.skillRepos ?? new Map(skills.map((s) => [s, null]));
17
19
  const globalInstalled = new Set(opts?.globalInstalled ?? []);
18
20
  const localInstalled = new Set(opts?.localInstalled ?? []);
19
21
 
@@ -40,6 +42,7 @@ function setup(opts?: {
40
42
  makeDeps() {
41
43
  return {
42
44
  allSkills: skills,
45
+ skillRepos,
43
46
  globalInstalled,
44
47
  localInstalled,
45
48
  catalogPath: "/tmp/test-catalog",
@@ -64,6 +67,7 @@ function setup(opts?: {
64
67
  makeDepsImmediate(toggleResult = true) {
65
68
  return {
66
69
  allSkills: skills,
70
+ skillRepos,
67
71
  globalInstalled,
68
72
  localInstalled,
69
73
  catalogPath: "/tmp/test-catalog",
@@ -563,3 +567,159 @@ test("empty catalog renders without crashing", async () => {
563
567
 
564
568
  renderer.destroy();
565
569
  });
570
+
571
+ // ── Repo grouping tests ───────────────────────────────────────────────
572
+
573
+ test("renders repo section headers", async () => {
574
+ const skills = ["alpha", "beta"];
575
+ const skillRepos = new Map<string, string | null>([
576
+ ["alpha", "owner/repo-a"],
577
+ ["beta", "owner/repo-a"],
578
+ ]);
579
+ const ctx = setup({ skills, skillRepos });
580
+ const { renderer, renderOnce, captureCharFrame } =
581
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
582
+
583
+ createTui(renderer, ctx.makeDepsImmediate());
584
+ await renderOnce();
585
+
586
+ const frame = captureCharFrame();
587
+ expect(frame).toContain("owner/repo-a");
588
+ expect(frame).toContain("alpha");
589
+ expect(frame).toContain("beta");
590
+
591
+ renderer.destroy();
592
+ });
593
+
594
+ test("groups skills under their repo header", async () => {
595
+ const skills = ["alpha", "beta", "gamma"];
596
+ const skillRepos = new Map<string, string | null>([
597
+ ["alpha", "owner/repo-b"],
598
+ ["beta", "owner/repo-a"],
599
+ ["gamma", "owner/repo-b"],
600
+ ]);
601
+ const ctx = setup({ skills, skillRepos });
602
+ const { renderer, renderOnce } =
603
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
604
+
605
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
606
+ await renderOnce();
607
+
608
+ const items = tui.state.displayItems;
609
+ // Repos sorted alphabetically: repo-a then repo-b
610
+ expect(items[0]).toEqual({ type: "header", repo: "owner/repo-a" });
611
+ expect(items[1]).toMatchObject({ type: "skill", name: "beta" });
612
+ expect(items[2]).toEqual({ type: "header", repo: "owner/repo-b" });
613
+ expect(items[3]).toMatchObject({ type: "skill", name: "alpha" });
614
+ expect(items[4]).toMatchObject({ type: "skill", name: "gamma" });
615
+
616
+ renderer.destroy();
617
+ });
618
+
619
+ test("cursor skips header rows", async () => {
620
+ const skills = ["alpha", "beta"];
621
+ const skillRepos = new Map<string, string | null>([
622
+ ["alpha", "owner/repo-a"],
623
+ ["beta", "owner/repo-b"],
624
+ ]);
625
+ const ctx = setup({ skills, skillRepos });
626
+ const { renderer, mockInput, renderOnce } =
627
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
628
+
629
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
630
+
631
+ // Enter grid
632
+ mockInput.pressArrow("down");
633
+ await renderOnce();
634
+ expect(tui.state.focusArea).toBe("grid");
635
+ expect(tui.state.cursor).toBe(0);
636
+ // First skill should be alpha (repo-a comes first)
637
+ expect(tui.state.currentSkillIndex()).not.toBeNull();
638
+ const firstName = skills[tui.state.currentSkillIndex()!];
639
+ expect(firstName).toBe("alpha");
640
+
641
+ // Move down — should land on beta, skipping repo-b header
642
+ mockInput.pressArrow("down");
643
+ await renderOnce();
644
+ expect(tui.state.cursor).toBe(1);
645
+ const secondName = skills[tui.state.currentSkillIndex()!];
646
+ expect(secondName).toBe("beta");
647
+
648
+ renderer.destroy();
649
+ });
650
+
651
+ test("search hides groups with no matching skills", async () => {
652
+ const skills = ["alpha", "beta", "gamma"];
653
+ const skillRepos = new Map<string, string | null>([
654
+ ["alpha", "owner/repo-a"],
655
+ ["beta", "owner/repo-a"],
656
+ ["gamma", "owner/repo-b"],
657
+ ]);
658
+ const ctx = setup({ skills, skillRepos });
659
+ const { renderer, mockInput, renderOnce, captureCharFrame } =
660
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
661
+
662
+ createTui(renderer, ctx.makeDepsImmediate());
663
+ await renderOnce();
664
+
665
+ // Search for "gamma" — only repo-b should be visible
666
+ mockInput.typeText("gamma");
667
+ await renderOnce();
668
+
669
+ const frame = captureCharFrame();
670
+ expect(frame).toContain("gamma");
671
+ expect(frame).toContain("owner/repo-b");
672
+ expect(frame).not.toContain("alpha");
673
+ expect(frame).not.toContain("beta");
674
+ // repo-a header should be hidden
675
+ expect(frame).not.toContain("owner/repo-a");
676
+
677
+ renderer.destroy();
678
+ });
679
+
680
+ test("ungrouped skills appear first with no header", async () => {
681
+ const skills = ["alpha", "beta", "gamma"];
682
+ const skillRepos = new Map<string, string | null>([
683
+ ["alpha", null],
684
+ ["beta", "owner/repo-a"],
685
+ ["gamma", null],
686
+ ]);
687
+ const ctx = setup({ skills, skillRepos });
688
+ const { renderer, renderOnce } =
689
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
690
+
691
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
692
+ await renderOnce();
693
+
694
+ const items = tui.state.displayItems;
695
+ // Ungrouped first: alpha, gamma (in original order)
696
+ expect(items[0]).toMatchObject({ type: "skill", name: "alpha" });
697
+ expect(items[1]).toMatchObject({ type: "skill", name: "gamma" });
698
+ // Then grouped with header
699
+ expect(items[2]).toEqual({ type: "header", repo: "owner/repo-a" });
700
+ expect(items[3]).toMatchObject({ type: "skill", name: "beta" });
701
+
702
+ renderer.destroy();
703
+ });
704
+
705
+ test("groups sorted alphabetically by repo name", async () => {
706
+ const skills = ["alpha", "beta", "gamma"];
707
+ const skillRepos = new Map<string, string | null>([
708
+ ["alpha", "z-org/zebra"],
709
+ ["beta", "a-org/apple"],
710
+ ["gamma", "m-org/mango"],
711
+ ]);
712
+ const ctx = setup({ skills, skillRepos });
713
+ const { renderer, renderOnce } =
714
+ await createTestRenderer({ width: WIDTH, height: HEIGHT });
715
+
716
+ const tui = createTui(renderer, ctx.makeDepsImmediate());
717
+ await renderOnce();
718
+
719
+ const headers = tui.state.displayItems
720
+ .filter((item): item is Extract<typeof item, { type: "header" }> => item.type === "header")
721
+ .map((h) => h.repo);
722
+ expect(headers).toEqual(["a-org/apple", "m-org/mango", "z-org/zebra"]);
723
+
724
+ renderer.destroy();
725
+ });
package/tui.ts CHANGED
@@ -14,6 +14,7 @@ export type ColId = "global" | "local";
14
14
 
15
15
  export type TuiDeps = {
16
16
  allSkills: string[];
17
+ skillRepos: Map<string, string | null>;
17
18
  globalInstalled: Set<string>;
18
19
  localInstalled: Set<string>;
19
20
  catalogPath: string;
@@ -27,6 +28,10 @@ export type TuiDeps = {
27
28
  onQuit: () => void;
28
29
  };
29
30
 
31
+ export type DisplayItem =
32
+ | { type: "skill"; name: string; skillIndex: number }
33
+ | { type: "header"; repo: string };
34
+
30
35
  export function createTui(renderer: CliRenderer, deps: TuiDeps) {
31
36
  const {
32
37
  allSkills,
@@ -44,6 +49,42 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
44
49
  return `${text.slice(0, max - 1)}\u2026`;
45
50
  }
46
51
 
52
+ // ── Build display list ─────────────────────────────────────────────
53
+
54
+ const displayItems: DisplayItem[] = [];
55
+ const skillIndexToDisplayIndex = new Map<number, number>();
56
+
57
+ {
58
+ const ungrouped: number[] = [];
59
+ const grouped = new Map<string, number[]>();
60
+
61
+ for (let i = 0; i < allSkills.length; i++) {
62
+ const repo = deps.skillRepos.get(allSkills[i]!) ?? null;
63
+ if (repo === null) {
64
+ ungrouped.push(i);
65
+ } else {
66
+ const list = grouped.get(repo) ?? [];
67
+ list.push(i);
68
+ grouped.set(repo, list);
69
+ }
70
+ }
71
+
72
+ for (const si of ungrouped) {
73
+ skillIndexToDisplayIndex.set(si, displayItems.length);
74
+ displayItems.push({ type: "skill", name: allSkills[si]!, skillIndex: si });
75
+ }
76
+
77
+ const sortedRepos = [...grouped.keys()].sort();
78
+ for (const repo of sortedRepos) {
79
+ displayItems.push({ type: "header", repo });
80
+ const skills = grouped.get(repo)!;
81
+ for (const si of skills) {
82
+ skillIndexToDisplayIndex.set(si, displayItems.length);
83
+ displayItems.push({ type: "skill", name: allSkills[si]!, skillIndex: si });
84
+ }
85
+ }
86
+ }
87
+
47
88
  // ── State ───────────────────────────────────────────────────────────
48
89
 
49
90
  let cursor = 0;
@@ -53,7 +94,9 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
53
94
  let pendingDelete: number | null = null;
54
95
  const deletedSkills = new Set<number>();
55
96
  let searchQuery = "";
56
- let filteredIndices: number[] = allSkills.map((_, i) => i);
97
+ let filteredDisplayIndices: number[] = displayItems
98
+ .map((item, di) => (item.type === "skill" ? di : -1))
99
+ .filter((di) => di >= 0);
57
100
  const COL_ORDER: ColId[] = ["global", "local"];
58
101
 
59
102
  const pendingToggles = new Map<string, "adding" | "removing">();
@@ -184,54 +227,89 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
184
227
  width: "100%",
185
228
  });
186
229
 
230
+ type HeaderRef = { spacer: BoxRenderable | null; row: BoxRenderable; text: TextRenderable };
231
+ const headerRefs = new Map<number, HeaderRef>();
232
+
187
233
  type RowRefs = {
188
234
  row: BoxRenderable;
189
235
  nameText: TextRenderable;
190
236
  globalText: TextRenderable;
191
237
  localText: TextRenderable;
192
238
  };
193
- const rows: RowRefs[] = [];
239
+ const rows: RowRefs[] = new Array(allSkills.length);
194
240
 
195
241
  scrollBox.add(searchRow);
196
242
 
197
- for (let i = 0; i < allSkills.length; i++) {
198
- const skill = allSkills[i]!;
199
-
200
- const row = new BoxRenderable(renderer, {
201
- id: `row-${i}`,
202
- flexDirection: "row",
203
- height: 1,
204
- width: "100%",
205
- paddingLeft: 1,
206
- backgroundColor: i % 2 === 0 ? C.rowBg : C.rowAltBg,
207
- });
208
-
209
- const nameText = new TextRenderable(renderer, {
210
- id: `name-${i}`,
211
- content: ` ${ellipsize(skill, NAME_W - 3)}`,
212
- fg: C.fg,
213
- width: NAME_W,
214
- });
215
-
216
- const globalText = new TextRenderable(renderer, {
217
- id: `global-${i}`,
218
- content: checkboxStr("global", skill),
219
- fg: checkboxColor("global", skill, false),
220
- width: COL_W,
221
- });
222
-
223
- const localText = new TextRenderable(renderer, {
224
- id: `local-${i}`,
225
- content: checkboxStr("local", skill),
226
- fg: checkboxColor("local", skill, false),
227
- width: COL_W,
228
- });
229
-
230
- row.add(nameText);
231
- row.add(globalText);
232
- row.add(localText);
233
- scrollBox.add(row);
234
- rows.push({ row, nameText, globalText, localText });
243
+ for (let di = 0; di < displayItems.length; di++) {
244
+ const item = displayItems[di]!;
245
+ if (item.type === "header") {
246
+ let spacer: BoxRenderable | null = null;
247
+ if (di > 0) {
248
+ spacer = new BoxRenderable(renderer, {
249
+ id: `spacer-${di}`,
250
+ height: 1,
251
+ width: "100%",
252
+ backgroundColor: C.rowBg,
253
+ });
254
+ scrollBox.add(spacer);
255
+ }
256
+ const hRow = new BoxRenderable(renderer, {
257
+ id: `header-${di}`,
258
+ flexDirection: "row",
259
+ height: 1,
260
+ width: "100%",
261
+ paddingLeft: 1,
262
+ backgroundColor: C.rowBg,
263
+ });
264
+ const hText = new TextRenderable(renderer, {
265
+ id: `header-text-${di}`,
266
+ content: ` ${item.repo}`,
267
+ fg: C.fgDim,
268
+ width: NAME_W + COL_W * 2,
269
+ });
270
+ hRow.add(hText);
271
+ scrollBox.add(hRow);
272
+ headerRefs.set(di, { spacer, row: hRow, text: hText });
273
+ } else {
274
+ const i = item.skillIndex;
275
+ const skill = allSkills[i]!;
276
+
277
+ const row = new BoxRenderable(renderer, {
278
+ id: `row-${i}`,
279
+ flexDirection: "row",
280
+ height: 1,
281
+ width: "100%",
282
+ paddingLeft: 1,
283
+ backgroundColor: C.rowBg,
284
+ });
285
+
286
+ const nameText = new TextRenderable(renderer, {
287
+ id: `name-${i}`,
288
+ content: ` ${ellipsize(skill, NAME_W - 3)}`,
289
+ fg: C.fg,
290
+ width: NAME_W,
291
+ });
292
+
293
+ const globalText = new TextRenderable(renderer, {
294
+ id: `global-${i}`,
295
+ content: checkboxStr("global", skill),
296
+ fg: checkboxColor("global", skill, false),
297
+ width: COL_W,
298
+ });
299
+
300
+ const localText = new TextRenderable(renderer, {
301
+ id: `local-${i}`,
302
+ content: checkboxStr("local", skill),
303
+ fg: checkboxColor("local", skill, false),
304
+ width: COL_W,
305
+ });
306
+
307
+ row.add(nameText);
308
+ row.add(globalText);
309
+ row.add(localText);
310
+ scrollBox.add(row);
311
+ rows[i] = { row, nameText, globalText, localText };
312
+ }
235
313
  }
236
314
 
237
315
  const footerSep = new TextRenderable(renderer, {
@@ -268,20 +346,40 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
268
346
 
269
347
  function applyFilter() {
270
348
  const term = searchQuery.toLowerCase();
271
- filteredIndices = [];
349
+ filteredDisplayIndices = [];
350
+
351
+ const matchingSkills = new Set<number>();
272
352
  for (let i = 0; i < allSkills.length; i++) {
273
- if (deletedSkills.has(i)) {
274
- rows[i]!.row.visible = false;
275
- continue;
353
+ if (deletedSkills.has(i)) continue;
354
+ if (!term || allSkills[i]!.toLowerCase().includes(term)) {
355
+ matchingSkills.add(i);
276
356
  }
277
- const match = !term || allSkills[i]!.toLowerCase().includes(term);
278
- rows[i]!.row.visible = match;
279
- if (match) filteredIndices.push(i);
280
357
  }
281
- if (filteredIndices.length === 0) {
358
+
359
+ const visibleRepos = new Set<string>();
360
+ for (const si of matchingSkills) {
361
+ const repo = deps.skillRepos.get(allSkills[si]!) ?? null;
362
+ if (repo !== null) visibleRepos.add(repo);
363
+ }
364
+
365
+ for (let di = 0; di < displayItems.length; di++) {
366
+ const item = displayItems[di]!;
367
+ if (item.type === "header") {
368
+ const visible = visibleRepos.has(item.repo);
369
+ const ref = headerRefs.get(di)!;
370
+ ref.row.visible = visible;
371
+ if (ref.spacer) ref.spacer.visible = visible;
372
+ } else {
373
+ const visible = matchingSkills.has(item.skillIndex);
374
+ rows[item.skillIndex]!.row.visible = visible;
375
+ if (visible) filteredDisplayIndices.push(di);
376
+ }
377
+ }
378
+
379
+ if (filteredDisplayIndices.length === 0) {
282
380
  cursor = 0;
283
- } else if (cursor >= filteredIndices.length) {
284
- cursor = filteredIndices.length - 1;
381
+ } else if (cursor >= filteredDisplayIndices.length) {
382
+ cursor = filteredDisplayIndices.length - 1;
285
383
  }
286
384
  }
287
385
 
@@ -300,7 +398,11 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
300
398
  // ── Update display ──────────────────────────────────────────────────
301
399
 
302
400
  function currentSkillIndex(): number | null {
303
- return filteredIndices.length > 0 ? (filteredIndices[cursor] ?? null) : null;
401
+ if (filteredDisplayIndices.length === 0) return null;
402
+ const di = filteredDisplayIndices[cursor];
403
+ if (di === undefined) return null;
404
+ const item = displayItems[di]!;
405
+ return item.type === "skill" ? item.skillIndex : null;
304
406
  }
305
407
 
306
408
  function updateRow(i: number) {
@@ -309,7 +411,8 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
309
411
  const ci = currentSkillIndex();
310
412
  const isCursor = ci === i && focusArea === "grid";
311
413
 
312
- const visPos = filteredIndices.indexOf(i);
414
+ const di = skillIndexToDisplayIndex.get(i)!;
415
+ const visPos = filteredDisplayIndices.indexOf(di);
313
416
  const baseBg = visPos % 2 === 0 ? C.rowBg : C.rowAltBg;
314
417
  r.row.backgroundColor = isCursor ? C.cursorBg : baseBg;
315
418
 
@@ -351,10 +454,16 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
351
454
  r.globalText.width = COL_W;
352
455
  r.localText.width = COL_W;
353
456
  }
457
+ for (const [, ref] of headerRefs) {
458
+ ref.text.width = NAME_W + COL_W * 2;
459
+ }
354
460
  }
355
461
 
356
462
  function refreshAll() {
357
- for (const i of filteredIndices) updateRow(i);
463
+ for (const di of filteredDisplayIndices) {
464
+ const item = displayItems[di]!;
465
+ if (item.type === "skill") updateRow(item.skillIndex);
466
+ }
358
467
  updateSearchBar();
359
468
  }
360
469
 
@@ -366,7 +475,22 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
366
475
  if (focusArea === "search") {
367
476
  scrollBox.scrollTo(0);
368
477
  } else {
369
- scrollBox.scrollTo(Math.max(0, cursor + 1 - 2));
478
+ const targetDi = filteredDisplayIndices[cursor];
479
+ if (targetDi === undefined) return;
480
+ let visibleBefore = 0;
481
+ for (let di = 0; di < targetDi; di++) {
482
+ const item = displayItems[di]!;
483
+ if (item.type === "header") {
484
+ const ref = headerRefs.get(di)!;
485
+ if (ref.row.visible) {
486
+ visibleBefore++;
487
+ if (ref.spacer?.visible) visibleBefore++;
488
+ }
489
+ } else {
490
+ if (rows[item.skillIndex]!.row.visible) visibleBefore++;
491
+ }
492
+ }
493
+ scrollBox.scrollTo(Math.max(0, visibleBefore + 1 - 2));
370
494
  }
371
495
  }
372
496
 
@@ -541,7 +665,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
541
665
  // ── Navigation & actions ──
542
666
  switch (key.name) {
543
667
  case "down":
544
- if (cursor < filteredIndices.length - 1) cursor++;
668
+ if (cursor < filteredDisplayIndices.length - 1) cursor++;
545
669
  break;
546
670
  case "up":
547
671
  if (cursor > 0) {
@@ -562,7 +686,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
562
686
  cursorCol = colNext(cursorCol);
563
687
  break;
564
688
  case "pagedown":
565
- cursor = Math.min(filteredIndices.length - 1, cursor + 10);
689
+ cursor = Math.min(filteredDisplayIndices.length - 1, cursor + 10);
566
690
  break;
567
691
  case "pageup":
568
692
  cursor = Math.max(0, cursor - 10);
@@ -571,7 +695,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
571
695
  cursor = 0;
572
696
  break;
573
697
  case "end":
574
- cursor = Math.max(0, filteredIndices.length - 1);
698
+ cursor = Math.max(0, filteredDisplayIndices.length - 1);
575
699
  break;
576
700
  case "space":
577
701
  case "return": {
@@ -602,7 +726,12 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
602
726
  cursorCol,
603
727
  focusArea,
604
728
  searchQuery,
605
- filteredIndices,
729
+ get filteredIndices() {
730
+ return filteredDisplayIndices.map(
731
+ (di) => (displayItems[di] as Extract<DisplayItem, { type: "skill" }>).skillIndex
732
+ );
733
+ },
734
+ displayItems,
606
735
  pendingDelete,
607
736
  pendingToggles,
608
737
  currentSkillIndex,