@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 +3 -2
- package/index.ts +36 -40
- package/lib.test.ts +49 -9
- package/lib.ts +54 -2
- package/migrate.ts +153 -0
- package/package.json +1 -1
- package/tsconfig.json +2 -1
- package/tui.test.ts +161 -1
- package/tui.ts +187 -58
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
|
|
20
|
-
if (!
|
|
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,
|
|
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,
|
|
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
|
-
// ──
|
|
56
|
+
// ── Read installed skills directly from filesystem ──────────────────
|
|
68
57
|
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} catch {
|
|
79
|
-
|
|
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
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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("
|
|
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
|
-
|
|
320
|
-
|
|
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
package/tsconfig.json
CHANGED
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
|
|
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
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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 >=
|
|
284
|
-
cursor =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 <
|
|
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(
|
|
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,
|
|
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,
|