@dealdeploy/skl 1.1.1 → 1.3.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
@@ -4,16 +4,23 @@ import { createCliRenderer } from "@opentui/core";
4
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 } from "./lib.ts";
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") {
@@ -24,21 +31,36 @@ Usage:
24
31
  skl add <repo> Add skills from a GitHub repo (interactive)
25
32
  skl add <repo> --all Add all skills from a repo (non-interactive)
26
33
  skl update Update all remote skills
34
+ skl migrate Migrate old symlinks to use the catalog
35
+ skl migrate -l Migrate local project symlinks
27
36
  skl help Show this help message
28
37
 
29
38
  Options:
30
39
  -h, --help Show this help message
31
- -v, --version Show version`);
40
+ -v, --version Show version
41
+ --debug Show full error output on failures`);
32
42
  process.exit(0);
33
43
  }
34
44
 
35
45
  // ── Subcommand routing ───────────────────────────────────────────────
46
+ async function printUpdateMsg() {
47
+ const msg = await updateMsg;
48
+ if (msg) console.log(`\n${msg}`);
49
+ }
50
+
36
51
  if (arg === "add") {
37
52
  await import("./add.ts");
53
+ await printUpdateMsg();
38
54
  process.exit(0);
39
55
  }
40
56
  if (arg === "update") {
41
57
  await import("./update.ts");
58
+ await printUpdateMsg();
59
+ process.exit(0);
60
+ }
61
+ if (arg === "migrate") {
62
+ await import("./migrate.ts");
63
+ await printUpdateMsg();
42
64
  process.exit(0);
43
65
  }
44
66
 
@@ -64,27 +86,47 @@ function readInstalledSkills(dir: string): Set<string> {
64
86
  const globalInstalled = readInstalledSkills(join(homedir(), ".claude/skills"));
65
87
  const localInstalled = readInstalledSkills(join(process.cwd(), ".claude/skills"));
66
88
 
89
+ // Always install to .agents/skills (universal), plus any other agent dirs found in the project.
90
+ const projectAgents = [...new Set(["universal", ...detectProjectAgents(process.cwd())])];
91
+
67
92
  // ── Create TUI ──────────────────────────────────────────────────────
68
93
 
69
94
  const renderer = await createCliRenderer({ exitOnCtrlC: true });
70
95
 
96
+ const allSkills = getCatalogSkills();
97
+ const lock = readLock();
98
+ const skillRepos = new Map<string, string | null>();
99
+ for (const name of allSkills) {
100
+ skillRepos.set(name, lock.skills[name]?.source || null);
101
+ }
102
+
71
103
  const tui = createTui(renderer, {
72
- allSkills: getCatalogSkills(),
104
+ allSkills,
105
+ skillRepos,
73
106
  globalInstalled,
74
107
  localInstalled,
75
108
  catalogPath: CATALOG,
76
109
 
77
110
  async onToggle(col: ColId, name: string, enable: boolean) {
78
111
  const isGlobal = col === "global";
112
+ const agentArgs = isGlobal ? [] : ["--agent", ...projectAgents];
79
113
  const args = enable
80
- ? buildAddArgs(CATALOG, name, isGlobal)
81
- : ["remove", name, "-y", ...(isGlobal ? ["-g"] : [])];
114
+ ? buildAddArgs(CATALOG, name, isGlobal, projectAgents)
115
+ : ["remove", name, "-y", ...agentArgs, ...(isGlobal ? ["-g"] : [])];
82
116
  const proc = Bun.spawn(["npx", "-y", "skills", ...args], {
83
117
  stdout: "pipe",
84
118
  stderr: "pipe",
85
119
  });
86
- const code = await proc.exited;
87
- 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;
88
130
  },
89
131
 
90
132
  async onDelete(name: string) {
@@ -98,7 +140,7 @@ const tui = createTui(renderer, {
98
140
  }
99
141
  if (localInstalled.has(name)) {
100
142
  procs.push(
101
- Bun.spawn(["npx", "-y", "skills", "remove", name, "-y"], {
143
+ Bun.spawn(["npx", "-y", "skills", "remove", name, "-y", "--agent", ...projectAgents], {
102
144
  stdout: "pipe", stderr: "pipe",
103
145
  })
104
146
  );
@@ -123,8 +165,9 @@ const tui = createTui(renderer, {
123
165
  process.exit(0);
124
166
  },
125
167
 
126
- onQuit() {
168
+ async onQuit() {
127
169
  renderer.destroy();
170
+ await printUpdateMsg();
128
171
  process.exit(0);
129
172
  },
130
173
  });
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.1",
3
+ "version": "1.3.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,11 +14,12 @@ 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;
20
- /** Called when a skill toggle is requested. Return true on success. */
21
- 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>;
22
23
  /** Called when a skill delete is requested. */
23
24
  onDelete: (name: string) => Promise<void>;
24
25
  /** Called when edit is requested. Return false to stay in TUI. */
@@ -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
 
@@ -381,8 +505,8 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
381
505
  refreshAll();
382
506
 
383
507
  try {
384
- const ok = await deps.onToggle(col, name, !enabled);
385
- if (ok) {
508
+ const result = await deps.onToggle(col, name, !enabled);
509
+ if (result === true) {
386
510
  const set = col === "global" ? globalInstalled : localInstalled;
387
511
  if (enabled) {
388
512
  set.delete(name);
@@ -392,7 +516,8 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
392
516
  setStatus(`Added ${name}`, C.statusOk);
393
517
  }
394
518
  } else {
395
- setStatus(`Failed to ${enabled ? "remove" : "add"} ${name}`, C.statusErr);
519
+ const detail = typeof result === "string" ? `: ${result}` : "";
520
+ setStatus(`Failed to ${enabled ? "remove" : "add"} ${name}${detail}`, C.statusErr);
396
521
  }
397
522
  } finally {
398
523
  pendingToggles.delete(key);
@@ -541,7 +666,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
541
666
  // ── Navigation & actions ──
542
667
  switch (key.name) {
543
668
  case "down":
544
- if (cursor < filteredIndices.length - 1) cursor++;
669
+ if (cursor < filteredDisplayIndices.length - 1) cursor++;
545
670
  break;
546
671
  case "up":
547
672
  if (cursor > 0) {
@@ -562,7 +687,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
562
687
  cursorCol = colNext(cursorCol);
563
688
  break;
564
689
  case "pagedown":
565
- cursor = Math.min(filteredIndices.length - 1, cursor + 10);
690
+ cursor = Math.min(filteredDisplayIndices.length - 1, cursor + 10);
566
691
  break;
567
692
  case "pageup":
568
693
  cursor = Math.max(0, cursor - 10);
@@ -571,7 +696,7 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
571
696
  cursor = 0;
572
697
  break;
573
698
  case "end":
574
- cursor = Math.max(0, filteredIndices.length - 1);
699
+ cursor = Math.max(0, filteredDisplayIndices.length - 1);
575
700
  break;
576
701
  case "space":
577
702
  case "return": {
@@ -602,7 +727,12 @@ export function createTui(renderer: CliRenderer, deps: TuiDeps) {
602
727
  cursorCol,
603
728
  focusArea,
604
729
  searchQuery,
605
- filteredIndices,
730
+ get filteredIndices() {
731
+ return filteredDisplayIndices.map(
732
+ (di) => (displayItems[di] as Extract<DisplayItem, { type: "skill" }>).skillIndex
733
+ );
734
+ },
735
+ displayItems,
606
736
  pendingDelete,
607
737
  pendingToggles,
608
738
  currentSkillIndex,
@@ -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
+ }