claudeup 6.3.2 → 6.5.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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-notice.test.ts +3 -3
  3. package/src/__tests__/cli-live.test.ts +9 -2
  4. package/src/__tests__/cli-update-view.test.ts +2 -2
  5. package/src/__tests__/footer-hints.test.ts +40 -0
  6. package/src/__tests__/gap-fill-versions.test.ts +24 -24
  7. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  8. package/src/__tests__/hook-import-policy.test.ts +90 -0
  9. package/src/__tests__/hook-process.test.ts +256 -0
  10. package/src/__tests__/hook-registration.test.ts +224 -0
  11. package/src/__tests__/manifest.test.ts +134 -0
  12. package/src/__tests__/marketplace-badge.test.ts +1 -1
  13. package/src/__tests__/marketplaces.test.ts +0 -1
  14. package/src/__tests__/model-visuals.test.tsx +793 -0
  15. package/src/__tests__/models-adapter.test.ts +317 -0
  16. package/src/__tests__/models-cli.test.ts +173 -0
  17. package/src/__tests__/models-core.test.ts +640 -0
  18. package/src/__tests__/models-manager.test.ts +497 -0
  19. package/src/__tests__/models-screen-state.test.ts +259 -0
  20. package/src/__tests__/moved-marketplace.test.ts +7 -8
  21. package/src/__tests__/plugin-contents.test.ts +1 -1
  22. package/src/__tests__/profile-adopt.test.ts +1 -1
  23. package/src/__tests__/profile-materializer.test.ts +48 -2
  24. package/src/__tests__/resolver.test.ts +43 -6
  25. package/src/__tests__/settings-file.test.ts +179 -0
  26. package/src/__tests__/symlink-manager.test.ts +65 -1
  27. package/src/__tests__/tabbar-layout.test.ts +40 -2
  28. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  29. package/src/__tests__/version-snapshot.test.ts +4 -4
  30. package/src/cli/doctor.ts +90 -0
  31. package/src/cli/hook.ts +129 -0
  32. package/src/cli/models.ts +214 -0
  33. package/src/cli/router.ts +12 -0
  34. package/src/data/gitignore-defaults.ts +4 -1
  35. package/src/data/gitignore-reasons.ts +0 -4
  36. package/src/data/marketplaces.ts +1 -15
  37. package/src/data/models-presets.ts +270 -0
  38. package/src/data/predefined-profiles.ts +12 -21
  39. package/src/data/settings-catalog.ts +11 -4
  40. package/src/main.tsx +51 -82
  41. package/src/services/hook-registration.ts +218 -0
  42. package/src/services/manifest.ts +84 -0
  43. package/src/services/models-core.ts +628 -0
  44. package/src/services/models-manager.ts +606 -0
  45. package/src/services/plugin-manager.ts +2 -3
  46. package/src/services/profile-materializer.ts +17 -0
  47. package/src/services/resolver.ts +13 -2
  48. package/src/services/settings-file.ts +69 -0
  49. package/src/services/styles-manager.ts +23 -45
  50. package/src/services/symlink-manager.ts +57 -11
  51. package/src/tui.tsx +112 -0
  52. package/src/types/bun.d.ts +21 -0
  53. package/src/types/index.ts +14 -0
  54. package/src/ui/App.tsx +15 -3
  55. package/src/ui/adapters/modelsAdapter.ts +170 -0
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/TabBar.tsx +9 -4
  58. package/src/ui/components/layout/FooterHints.tsx +20 -3
  59. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  60. package/src/ui/components/primitives/MetaText.tsx +27 -1
  61. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  62. package/src/ui/renderers/modelVisuals.tsx +853 -0
  63. package/src/ui/renderers/skillRenderers.tsx +13 -3
  64. package/src/ui/renderers/styleRenderers.tsx +7 -3
  65. package/src/ui/screens/ModelsScreen.tsx +478 -0
  66. package/src/ui/screens/PluginsScreen.tsx +1 -1
  67. package/src/ui/screens/StylesScreen.tsx +8 -13
  68. package/src/ui/screens/index.ts +1 -0
  69. package/src/ui/state/reducer.ts +94 -0
  70. package/src/ui/state/types.ts +65 -2
  71. package/src/ui/theme-mode.ts +116 -0
  72. package/src/ui/theme.ts +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "6.3.2",
3
+ "version": "6.5.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "6.3.2",
68
- "claudeup-darwin-x64": "6.3.2",
69
- "claudeup-linux-x64": "6.3.2"
67
+ "claudeup-darwin-arm64": "6.5.0",
68
+ "claudeup-darwin-x64": "6.5.0",
69
+ "claudeup-linux-x64": "6.5.0"
70
70
  }
71
71
  }
@@ -57,12 +57,12 @@ describe("buildCatalogNoticeText", () => {
57
57
 
58
58
  it("names one or two failing marketplaces but only counts many", () => {
59
59
  const few = buildCatalogNoticeText({
60
- failures: [failure("magus"), failure("magus-alpha")],
60
+ failures: [failure("magus"), failure("magus-marketing")],
61
61
  unverifiedPlugins: 0,
62
62
  width: 160,
63
63
  now: NOW,
64
64
  });
65
- expect(few).toContain("magus-alpha");
65
+ expect(few).toContain("magus-marketing");
66
66
 
67
67
  const many = buildCatalogNoticeText({
68
68
  failures: ["a", "b", "c", "d", "e", "f"].map((n) => failure(n)),
@@ -89,7 +89,7 @@ describe("buildCatalogNoticeText", () => {
89
89
  const line = buildCatalogNoticeText({
90
90
  failures: [
91
91
  failure("magus", "rate-limited", NOW + 600_000),
92
- failure("magus-alpha", "rate-limited", NOW + 45_000),
92
+ failure("magus-marketing", "rate-limited", NOW + 45_000),
93
93
  ],
94
94
  unverifiedPlugins: 2,
95
95
  width: 160,
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import { afterEach, describe, expect, test } from "bun:test";
22
+ import { width } from "../cli/ansi.js";
22
23
  import { LiveRegion, withPaused } from "../cli/live.js";
23
24
 
24
25
  /** A write stream that records instead of drawing. */
@@ -157,7 +158,12 @@ describe("animated mode", () => {
157
158
  const { live, writes } = region({ columns: 20 });
158
159
  live.start(() => ["x".repeat(40)]);
159
160
  const painted = writes.at(-1) ?? "";
160
- expect(painted.replace(/\n$/, "")).toHaveLength(19);
161
+ // DISPLAY width, not `.length`. `truncate` appends a reset when colour is
162
+ // on, and colour is on exactly when stdout is a real terminal — so a
163
+ // `.length` assertion passed piped and failed the moment anyone ran the
164
+ // suite in a terminal, over four zero-width bytes. The claim being made
165
+ // here is about columns, so measure columns.
166
+ expect(width(painted.replace(/\n$/, ""))).toBe(19);
161
167
  live.stop();
162
168
  });
163
169
 
@@ -202,7 +208,8 @@ describe("resize", () => {
202
208
  // biome-ignore lint/suspicious/noControlCharactersInRegex: ESC is the thing being matched.
203
209
  const CURSOR_UP = /\x1b\[\d+A/;
204
210
  expect(writes.some((w) => CURSOR_UP.test(w))).toBe(false);
205
- expect(writes.at(-1)?.replace(/\n$/, "")).toHaveLength(39);
211
+ // Display width, for the reason given on the clipping test above.
212
+ expect(width((writes.at(-1) ?? "").replace(/\n$/, ""))).toBe(39);
206
213
  live.stop();
207
214
  });
208
215
 
@@ -62,7 +62,7 @@ function plan(over: Partial<UpdatePlan> = {}): UpdatePlan {
62
62
  scopes: [],
63
63
  },
64
64
  {
65
- pluginId: "seo@magus",
65
+ pluginId: "designer@magus",
66
66
  pinned: "latest",
67
67
  installed: "1.8.0",
68
68
  available: null,
@@ -182,7 +182,7 @@ describe("NO_COLOR", () => {
182
182
 
183
183
  test("a note rides along on the row it explains", () => {
184
184
  setColorEnabled(false);
185
- const row = planReport(plan()).find((l) => l.includes("seo@magus"))!;
185
+ const row = planReport(plan()).find((l) => l.includes("designer@magus"))!;
186
186
  expect(row).toContain("catalog unreachable");
187
187
  });
188
188
  });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `joinKeys` decides how a key group reads in the footer chip.
3
+ *
4
+ * Found on screen, not in review: the Models tab advertised apply as `Entera`, and the
5
+ * layout's own scroll hint had been rendering as `^U/^DPgUp/Dn` on EVERY screen in the app.
6
+ * Both are two key names concatenated with nothing between them, which reads as one key that
7
+ * does not exist.
8
+ */
9
+ import { describe, expect, test } from "bun:test";
10
+ import { joinKeys } from "../ui/components/layout/FooterHints.js";
11
+
12
+ describe("joinKeys", () => {
13
+ test("single-character keys butt together, the way an arrow pair is written", () => {
14
+ expect(joinKeys(["↑", "↓"])).toBe("↑↓");
15
+ expect(joinKeys(["j", "k"])).toBe("jk");
16
+ });
17
+
18
+ test("one key is itself", () => {
19
+ expect(joinKeys(["U"])).toBe("U");
20
+ expect(joinKeys(["Enter"])).toBe("Enter");
21
+ });
22
+
23
+ test("named keys get a gap — the regression this exists for", () => {
24
+ expect(joinKeys(["Enter", "a"])).toBe("Enter a");
25
+ expect(joinKeys(["^U/^D", "PgUp/Dn"])).toBe("^U/^D PgUp/Dn");
26
+ });
27
+
28
+ test("a space, not a slash: several key names already contain one", () => {
29
+ expect(joinKeys(["^U/^D", "PgUp/Dn"])).not.toContain("//");
30
+ });
31
+
32
+ test("a multi-byte single character still counts as one", () => {
33
+ // "↑".length is 1 but an emoji's is 2 — count code points, not UTF-16 units.
34
+ expect(joinKeys(["⌘", "⇧"])).toBe("⌘⇧");
35
+ });
36
+
37
+ test("an empty group is empty rather than a stray separator", () => {
38
+ expect(joinKeys([])).toBe("");
39
+ });
40
+ });
@@ -117,20 +117,20 @@ describe("gapFillInstalledPluginVersions", () => {
117
117
  await fs.remove(initTmpHome).catch(() => {});
118
118
  });
119
119
 
120
- // Scenario 1: "The kanban bug" — project has newer version, global is stale
121
- it("bumps global when project has a newer version (the kanban bug)", async () => {
120
+ // Scenario 1: "The stale-global bug" — project has newer version, global is stale
121
+ it("bumps global when project has a newer version (the stale-global bug)", async () => {
122
122
  await writeGlobalSettings({
123
- installedPluginVersions: { "kanban@magus": "1.0.0" },
123
+ installedPluginVersions: { "designer@magus": "1.0.0" },
124
124
  });
125
125
  await writeProjectSettings({
126
- installedPluginVersions: { "kanban@magus": "1.3.0" },
126
+ installedPluginVersions: { "designer@magus": "1.3.0" },
127
127
  });
128
128
 
129
129
  const result = await gapFillInstalledPluginVersions(tmpProject);
130
130
 
131
131
  expect(result).toEqual([
132
132
  {
133
- pluginId: "kanban@magus",
133
+ pluginId: "designer@magus",
134
134
  oldVersion: "1.0.0",
135
135
  newVersion: "1.3.0",
136
136
  },
@@ -138,7 +138,7 @@ describe("gapFillInstalledPluginVersions", () => {
138
138
 
139
139
  // Verify the file was actually updated
140
140
  const global = await readGlobalSettingsFromDisk();
141
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
141
+ expect((global as any).installedPluginVersions["designer@magus"]).toBe(
142
142
  "1.3.0",
143
143
  );
144
144
  });
@@ -146,10 +146,10 @@ describe("gapFillInstalledPluginVersions", () => {
146
146
  // Scenario 2: Global is already current — no changes needed
147
147
  it("returns empty array when global is already at the highest version", async () => {
148
148
  await writeGlobalSettings({
149
- installedPluginVersions: { "kanban@magus": "1.3.0" },
149
+ installedPluginVersions: { "designer@magus": "1.3.0" },
150
150
  });
151
151
  await writeProjectSettings({
152
- installedPluginVersions: { "kanban@magus": "1.3.0" },
152
+ installedPluginVersions: { "designer@magus": "1.3.0" },
153
153
  });
154
154
 
155
155
  const result = await gapFillInstalledPluginVersions(tmpProject);
@@ -158,7 +158,7 @@ describe("gapFillInstalledPluginVersions", () => {
158
158
 
159
159
  // Verify global was NOT rewritten (check original content is intact)
160
160
  const global = await readGlobalSettingsFromDisk();
161
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
161
+ expect((global as any).installedPluginVersions["designer@magus"]).toBe(
162
162
  "1.3.0",
163
163
  );
164
164
  });
@@ -298,10 +298,10 @@ describe("gapFillInstalledPluginVersions", () => {
298
298
  // Scenario 9: Global version is higher than project — no downgrade
299
299
  it("does not downgrade global when global is higher than project", async () => {
300
300
  await writeGlobalSettings({
301
- installedPluginVersions: { "kanban@magus": "2.0.0" },
301
+ installedPluginVersions: { "designer@magus": "2.0.0" },
302
302
  });
303
303
  await writeProjectSettings({
304
- installedPluginVersions: { "kanban@magus": "1.0.0" },
304
+ installedPluginVersions: { "designer@magus": "1.0.0" },
305
305
  });
306
306
 
307
307
  const result = await gapFillInstalledPluginVersions(tmpProject);
@@ -310,7 +310,7 @@ describe("gapFillInstalledPluginVersions", () => {
310
310
 
311
311
  // Verify global was NOT downgraded
312
312
  const global = await readGlobalSettingsFromDisk();
313
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
313
+ expect((global as any).installedPluginVersions["designer@magus"]).toBe(
314
314
  "2.0.0",
315
315
  );
316
316
  });
@@ -318,12 +318,12 @@ describe("gapFillInstalledPluginVersions", () => {
318
318
  // Scenario 10: Preserves unrelated settings fields during write
319
319
  it("preserves other global settings fields when bumping versions", async () => {
320
320
  await writeGlobalSettings({
321
- enabledPlugins: { "kanban@magus": true, "dev@magus": true },
321
+ enabledPlugins: { "designer@magus": true, "dev@magus": true },
322
322
  env: { SOME_VAR: "value" },
323
- installedPluginVersions: { "kanban@magus": "1.0.0" },
323
+ installedPluginVersions: { "designer@magus": "1.0.0" },
324
324
  });
325
325
  await writeProjectSettings({
326
- installedPluginVersions: { "kanban@magus": "2.0.0" },
326
+ installedPluginVersions: { "designer@magus": "2.0.0" },
327
327
  });
328
328
 
329
329
  const result = await gapFillInstalledPluginVersions(tmpProject);
@@ -333,11 +333,11 @@ describe("gapFillInstalledPluginVersions", () => {
333
333
  // Verify other fields are preserved
334
334
  const global = await readGlobalSettingsFromDisk();
335
335
  expect((global as any).enabledPlugins).toEqual({
336
- "kanban@magus": true,
336
+ "designer@magus": true,
337
337
  "dev@magus": true,
338
338
  });
339
339
  expect((global as any).env).toEqual({ SOME_VAR: "value" });
340
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
340
+ expect((global as any).installedPluginVersions["designer@magus"]).toBe(
341
341
  "2.0.0",
342
342
  );
343
343
  });
@@ -345,24 +345,24 @@ describe("gapFillInstalledPluginVersions", () => {
345
345
  // Scenario 11: Invalid semver in GLOBAL scope gets overwritten
346
346
  it("overwrites corrupted global version when project has valid semver", async () => {
347
347
  await writeGlobalSettings({
348
- installedPluginVersions: { "kanban@magus": "not-a-version" },
348
+ installedPluginVersions: { "designer@magus": "not-a-version" },
349
349
  });
350
350
  await writeProjectSettings({
351
- installedPluginVersions: { "kanban@magus": "1.3.0" },
351
+ installedPluginVersions: { "designer@magus": "1.3.0" },
352
352
  });
353
353
 
354
354
  const result = await gapFillInstalledPluginVersions(tmpProject);
355
355
 
356
356
  expect(result).toEqual([
357
357
  {
358
- pluginId: "kanban@magus",
358
+ pluginId: "designer@magus",
359
359
  oldVersion: "not-a-version",
360
360
  newVersion: "1.3.0",
361
361
  },
362
362
  ]);
363
363
 
364
364
  const global = await readGlobalSettingsFromDisk();
365
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
365
+ expect((global as any).installedPluginVersions["designer@magus"]).toBe(
366
366
  "1.3.0",
367
367
  );
368
368
  });
@@ -370,10 +370,10 @@ describe("gapFillInstalledPluginVersions", () => {
370
370
  // Scenario 12: Both global and project have invalid semver — no crash, no change
371
371
  it("does nothing when all versions are invalid semver", async () => {
372
372
  await writeGlobalSettings({
373
- installedPluginVersions: { "kanban@magus": "broken" },
373
+ installedPluginVersions: { "designer@magus": "broken" },
374
374
  });
375
375
  await writeProjectSettings({
376
- installedPluginVersions: { "kanban@magus": "also-broken" },
376
+ installedPluginVersions: { "designer@magus": "also-broken" },
377
377
  });
378
378
 
379
379
  const result = await gapFillInstalledPluginVersions(tmpProject);
@@ -382,7 +382,7 @@ describe("gapFillInstalledPluginVersions", () => {
382
382
 
383
383
  // Global unchanged
384
384
  const global = await readGlobalSettingsFromDisk();
385
- expect((global as any).installedPluginVersions["kanban@magus"]).toBe(
385
+ expect((global as any).installedPluginVersions["designer@magus"]).toBe(
386
386
  "broken",
387
387
  );
388
388
  });
@@ -3,6 +3,7 @@ import { spawnSync } from "node:child_process";
3
3
  import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
+ import { BUILTIN_DEFAULTS } from "../data/gitignore-defaults.js";
6
7
  import {
7
8
  checkGitignore,
8
9
  formatPrerunWarning,
@@ -40,21 +41,13 @@ describe("checkGitignore", () => {
40
41
  git(projectDir, "init", "-q");
41
42
  git(projectDir, "config", "user.email", "t@t");
42
43
  git(projectDir, "config", "user.name", "t");
43
- // Suppress all built-in ignore rules
44
+ // Suppress all built-in ignore rules. DERIVED from the constant, not a
45
+ // hand-copied list: a copy silently becomes a different assertion the
46
+ // moment a pattern is added, and then fails for a reason that has nothing
47
+ // to do with what this test is about.
44
48
  await writeFile(
45
49
  join(projectDir, ".gitignore"),
46
- `${[
47
- ".claude/settings.local.json",
48
- ".claude/scheduled_tasks.lock",
49
- ".claude/cache/",
50
- ".claude/.statusline-worktree-*",
51
- ".claude/_profiles/",
52
- "ai-docs/sessions/",
53
- ".mnemex/",
54
- ".claudemem/",
55
- "gtd/sessions/",
56
- ".agents/",
57
- ].join("\n")}\n`,
50
+ `${BUILTIN_DEFAULTS.ignore.join("\n")}\n`,
58
51
  );
59
52
  // Tracked files don't have to exist for the absent-track-path case to be ignored
60
53
  const r = await checkGitignore(projectDir);
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The hook path must never load the TUI.
3
+ *
4
+ * Claude Code runs `claudeup hook …` on every matching tool call, so anything
5
+ * the entry point imports is paid for on every tool call — and `@opentui/*`
6
+ * plus the `src/ui/` tree is the expensive half of this program. Two guards,
7
+ * because they fail differently:
8
+ *
9
+ * - H-1 BUNDLES `src/cli/hook.ts` and inspects the output. A transitive import
10
+ * added three modules deep still shows up here.
11
+ * - H-2 reads `src/main.tsx` as text. Bundling cannot see this one: the TUI is
12
+ * reachable from main.tsx by design, just only through a dynamic import that
13
+ * the hook branch returns before. A *static* import would re-couple them
14
+ * while every runtime test still passed, so the source is what is pinned —
15
+ * the same technique dual-write-prevention.test.ts uses.
16
+ */
17
+
18
+ import { describe, expect, test } from "bun:test";
19
+ import { readFileSync } from "node:fs";
20
+ import path from "node:path";
21
+
22
+ const HOOK_ENTRY = path.join(import.meta.dir, "..", "cli", "hook.ts");
23
+ const MAIN_TSX = path.join(import.meta.dir, "..", "main.tsx");
24
+
25
+ /** Drop `//` line comments and `/* *\/` blocks so the guards match code only. */
26
+ function stripComments(source: string): string {
27
+ return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
28
+ }
29
+
30
+ describe("hook entry point", () => {
31
+ test("H-1: bundling src/cli/hook.ts pulls in neither @opentui nor src/ui", async () => {
32
+ const build = await Bun.build({
33
+ entrypoints: [HOOK_ENTRY],
34
+ target: "bun",
35
+ });
36
+
37
+ expect(build.logs).toEqual([]);
38
+ expect(build.success).toBe(true);
39
+ expect(build.outputs.length).toBeGreaterThan(0);
40
+
41
+ const bundle = (
42
+ await Promise.all(build.outputs.map((output) => output.text()))
43
+ ).join("\n");
44
+
45
+ expect(bundle).not.toContain("@opentui");
46
+ expect(bundle).not.toContain("/ui/");
47
+ });
48
+ });
49
+
50
+ describe("main.tsx import policy", () => {
51
+ const source = stripComments(readFileSync(MAIN_TSX, "utf-8"));
52
+
53
+ /**
54
+ * Every STATIC import/export specifier. `from "…"` only, which is exactly
55
+ * what a dynamic `import("…")` does not have — so the dynamic loads main.tsx
56
+ * relies on are invisible here, and that is the point.
57
+ */
58
+ const staticSpecifiers = [
59
+ ...source.matchAll(/\bfrom\s+["']([^"']+)["']/g),
60
+ ].map((match) => match[1] as string);
61
+
62
+ test("H-2: no static import of @opentui/*", () => {
63
+ expect(staticSpecifiers.filter((s) => s.startsWith("@opentui"))).toEqual(
64
+ [],
65
+ );
66
+ });
67
+
68
+ test("H-2b: no static import of ./ui/*", () => {
69
+ expect(staticSpecifiers.filter((s) => s.startsWith("./ui/"))).toEqual([]);
70
+ });
71
+
72
+ test("H-2c: the guard sees the specifiers it is meant to see", () => {
73
+ // Negative control: a regex that matched nothing would pass H-2 and H-2b
74
+ // forever. main.tsx does still carry static imports — just harmless ones.
75
+ expect(staticSpecifiers.length).toBeGreaterThan(0);
76
+ expect(staticSpecifiers).toContain("./services/dotenv.js");
77
+ });
78
+
79
+ test("H-3: the hook branch is dispatched before the .env load", () => {
80
+ // Ordering, at source level, for the same reason theme-env.test.ts pins
81
+ // the snapshot: a hook must never open a project .env — it may be a
82
+ // symlink to a FIFO, and reading one blocks on every tool call.
83
+ const hookAt = source.indexOf('await import("./cli/hook.js")');
84
+ const dotenvAt = source.indexOf("loadProjectDotenv(");
85
+
86
+ expect(hookAt).toBeGreaterThan(-1);
87
+ expect(dotenvAt).toBeGreaterThan(-1);
88
+ expect(hookAt).toBeLessThan(dotenvAt);
89
+ });
90
+ });
@@ -0,0 +1,256 @@
1
+ /**
2
+ * The hook as Claude Code actually runs it: a spawned process, a JSON payload on stdin, and
3
+ * whatever lands on stdout.
4
+ *
5
+ * `models-core.test.ts` covers the decision logic in-process. This file covers the things only
6
+ * a real spawn can show — that the binary starts, that it reads stdin, that it finds a config
7
+ * by walking up from the payload's cwd, and above all that EVERY failure mode is silent and
8
+ * exit 0. Claude Code spawns this on every Agent call in every session on the machine, so a
9
+ * hook that throws, hangs or exits non-zero would break delegation everywhere at once.
10
+ *
11
+ * The negative control is the point of the file: without a config, the same invocation must
12
+ * produce nothing at all. A test that only checks the happy path cannot tell a working hook
13
+ * from one that injects unconditionally.
14
+ */
15
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
16
+ import {
17
+ mkdirSync,
18
+ mkdtempSync,
19
+ rmSync,
20
+ symlinkSync,
21
+ writeFileSync,
22
+ } from "node:fs";
23
+ import { tmpdir } from "node:os";
24
+ import { dirname, join } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+
27
+ const MAIN = join(dirname(fileURLToPath(import.meta.url)), "..", "main.tsx");
28
+
29
+ let dir: string;
30
+
31
+ interface RunResult {
32
+ stdout: string;
33
+ stderr: string;
34
+ exitCode: number;
35
+ ms: number;
36
+ }
37
+
38
+ /** Spawn the hook exactly as a settings.json `command` entry would. */
39
+ async function runHook(
40
+ payload: unknown,
41
+ opts: { cwd?: string; args?: string[] } = {},
42
+ ): Promise<RunResult> {
43
+ const started = Date.now();
44
+ const proc = Bun.spawn(
45
+ ["bun", "--no-env-file", MAIN, "hook", ...(opts.args ?? ["agent-model"])],
46
+ {
47
+ cwd: opts.cwd ?? dir,
48
+ stdin: new TextEncoder().encode(
49
+ typeof payload === "string" ? payload : JSON.stringify(payload),
50
+ ),
51
+ stdout: "pipe",
52
+ stderr: "pipe",
53
+ },
54
+ );
55
+ const [stdout, stderr, exitCode] = await Promise.all([
56
+ new Response(proc.stdout).text(),
57
+ new Response(proc.stderr).text(),
58
+ proc.exited,
59
+ ]);
60
+ return { stdout, stderr, exitCode, ms: Date.now() - started };
61
+ }
62
+
63
+ const CONFIG = {
64
+ version: 1,
65
+ preset: "test",
66
+ main: { model: "opus", effort: "medium" },
67
+ grades: {
68
+ smart: { model: "fable", effort: "xhigh" },
69
+ normal: { model: "opus", effort: "medium" },
70
+ cheap: { model: "sonnet", effort: "low" },
71
+ },
72
+ agents: {
73
+ "dev:architect": "smart",
74
+ Explore: "cheap",
75
+ "dev:researcher": { model: "inherit" },
76
+ },
77
+ fallback: "normal",
78
+ };
79
+
80
+ function writeConfig(at: string, config: unknown = CONFIG): void {
81
+ mkdirSync(join(at, ".claude"), { recursive: true });
82
+ writeFileSync(
83
+ join(at, ".claude", "models.json"),
84
+ JSON.stringify(config, null, 2),
85
+ );
86
+ }
87
+
88
+ const agentPayload = (input: Record<string, unknown> = {}, cwd = dir) => ({
89
+ hook_event_name: "PreToolUse",
90
+ tool_name: "Agent",
91
+ cwd,
92
+ tool_input: { subagent_type: "dev:architect", prompt: "go", ...input },
93
+ });
94
+
95
+ beforeEach(() => {
96
+ dir = mkdtempSync(join(tmpdir(), "claudeup-hook-"));
97
+ });
98
+ afterEach(() => {
99
+ rmSync(dir, { recursive: true, force: true });
100
+ });
101
+
102
+ describe("claudeup hook agent-model", () => {
103
+ test("injects the routed model, and emits hookEventName", async () => {
104
+ writeConfig(dir);
105
+ const r = await runHook(agentPayload());
106
+ expect(r.exitCode).toBe(0);
107
+ expect(r.stderr).toBe("");
108
+ expect(JSON.parse(r.stdout)).toEqual({
109
+ hookSpecificOutput: {
110
+ hookEventName: "PreToolUse",
111
+ updatedInput: {
112
+ subagent_type: "dev:architect",
113
+ prompt: "go",
114
+ model: "fable",
115
+ },
116
+ },
117
+ });
118
+ });
119
+
120
+ test("NEGATIVE CONTROL: no config means no output at all", async () => {
121
+ // Without this the happy path above cannot distinguish routing from injecting always.
122
+ const r = await runHook(agentPayload());
123
+ expect(r.exitCode).toBe(0);
124
+ expect(r.stdout).toBe("");
125
+ expect(r.stderr).toBe("");
126
+ });
127
+
128
+ test("walks up to a config in a parent directory", async () => {
129
+ writeConfig(dir);
130
+ const nested = join(dir, "packages", "api", "src");
131
+ mkdirSync(nested, { recursive: true });
132
+ const r = await runHook(agentPayload({}, nested));
133
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
134
+ "fable",
135
+ );
136
+ });
137
+
138
+ test("uses the payload's cwd, not the process's", async () => {
139
+ // A session's directory is not where this process was started, and the payload is the
140
+ // only thing that knows the difference.
141
+ const elsewhere = mkdtempSync(join(tmpdir(), "claudeup-hook-other-"));
142
+ try {
143
+ writeConfig(elsewhere);
144
+ const r = await runHook(agentPayload({}, elsewhere), { cwd: dir });
145
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
146
+ "fable",
147
+ );
148
+ } finally {
149
+ rmSync(elsewhere, { recursive: true, force: true });
150
+ }
151
+ });
152
+
153
+ test("respects a model the caller already set", async () => {
154
+ writeConfig(dir);
155
+ const r = await runHook(agentPayload({ model: "haiku" }));
156
+ expect(r.stdout).toBe("");
157
+ });
158
+
159
+ test("`inherit` leaves the call alone", async () => {
160
+ writeConfig(dir);
161
+ const r = await runHook(agentPayload({ subagent_type: "dev:researcher" }));
162
+ expect(r.stdout).toBe("");
163
+ });
164
+
165
+ test("an unknown agent takes the fallback grade", async () => {
166
+ writeConfig(dir);
167
+ const r = await runHook(agentPayload({ subagent_type: "brand-new:agent" }));
168
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
169
+ "opus",
170
+ );
171
+ });
172
+
173
+ test("ignores a tool that is not Agent", async () => {
174
+ writeConfig(dir);
175
+ const r = await runHook({
176
+ tool_name: "Bash",
177
+ cwd: dir,
178
+ tool_input: { command: "ls" },
179
+ });
180
+ expect(r.stdout).toBe("");
181
+ expect(r.exitCode).toBe(0);
182
+ });
183
+
184
+ describe("fails open", () => {
185
+ test("garbage on stdin", async () => {
186
+ writeConfig(dir);
187
+ const r = await runHook("not json at all {{{");
188
+ expect(r.exitCode).toBe(0);
189
+ expect(r.stdout).toBe("");
190
+ });
191
+
192
+ test("empty stdin", async () => {
193
+ writeConfig(dir);
194
+ const r = await runHook("");
195
+ expect(r.exitCode).toBe(0);
196
+ expect(r.stdout).toBe("");
197
+ });
198
+
199
+ test("an unparseable config", async () => {
200
+ mkdirSync(join(dir, ".claude"), { recursive: true });
201
+ writeFileSync(join(dir, ".claude", "models.json"), "{ broken");
202
+ const r = await runHook(agentPayload());
203
+ expect(r.exitCode).toBe(0);
204
+ expect(r.stdout).toBe("");
205
+ });
206
+
207
+ test("an INVALID config routes nothing rather than injecting a bad model", async () => {
208
+ // Measured (AMR-1 bogus-model): a non-alias refuses the spawn outright, so injecting
209
+ // one would break every delegation for everyone who pulled the config.
210
+ writeConfig(dir, {
211
+ ...CONFIG,
212
+ grades: { ...CONFIG.grades, smart: { model: "kimi-k2" } },
213
+ });
214
+ const r = await runHook(agentPayload());
215
+ expect(r.exitCode).toBe(0);
216
+ expect(r.stdout).toBe("");
217
+ });
218
+
219
+ test("a config from a future version", async () => {
220
+ writeConfig(dir, { ...CONFIG, version: 99 });
221
+ const r = await runHook(agentPayload());
222
+ expect(r.exitCode).toBe(0);
223
+ expect(r.stdout).toBe("");
224
+ });
225
+
226
+ test("an unrecognised hook name", async () => {
227
+ writeConfig(dir);
228
+ const r = await runHook(agentPayload(), { args: ["something-else"] });
229
+ expect(r.exitCode).toBe(0);
230
+ expect(r.stdout).toBe("");
231
+ });
232
+ });
233
+
234
+ test("a .env symlinked to a FIFO does not hang the hook", async () => {
235
+ // In this repo `.env` is often a symlink to a 1Password FIFO. Bun autoloads the cwd's
236
+ // .env before user code unless told not to, and reading a FIFO with no writer blocks
237
+ // forever — which would look exactly like Claude Code freezing on every Agent call.
238
+ writeConfig(dir);
239
+ const fifo = join(dir, "fifo-target");
240
+ Bun.spawnSync(["mkfifo", fifo]);
241
+ symlinkSync(fifo, join(dir, ".env"));
242
+ const r = await runHook(agentPayload());
243
+ expect(r.exitCode).toBe(0);
244
+ expect(JSON.parse(r.stdout).hookSpecificOutput.updatedInput.model).toBe(
245
+ "fable",
246
+ );
247
+ });
248
+
249
+ test("stays well inside a hook timeout", async () => {
250
+ writeConfig(dir);
251
+ const r = await runHook(agentPayload());
252
+ // Registered with timeout: 5s. From source (not the compiled binary) this measures
253
+ // ~0.02-0.3s; a regression that pulls the TUI back onto this path would blow past it.
254
+ expect(r.ms).toBeLessThan(3000);
255
+ });
256
+ });