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.
- package/package.json +4 -4
- package/src/__tests__/catalog-notice.test.ts +3 -3
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/cli-update-view.test.ts +2 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gap-fill-versions.test.ts +24 -24
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/manifest.test.ts +134 -0
- package/src/__tests__/marketplace-badge.test.ts +1 -1
- package/src/__tests__/marketplaces.test.ts +0 -1
- package/src/__tests__/model-visuals.test.tsx +793 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/moved-marketplace.test.ts +7 -8
- package/src/__tests__/plugin-contents.test.ts +1 -1
- package/src/__tests__/profile-adopt.test.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +48 -2
- package/src/__tests__/resolver.test.ts +43 -6
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/__tests__/version-snapshot.test.ts +4 -4
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -1
- package/src/data/gitignore-reasons.ts +0 -4
- package/src/data/marketplaces.ts +1 -15
- package/src/data/models-presets.ts +270 -0
- package/src/data/predefined-profiles.ts +12 -21
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-manager.ts +2 -3
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +13 -2
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/PluginsScreen.tsx +1 -1
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- 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
|
+
"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.
|
|
68
|
-
"claudeup-darwin-x64": "6.
|
|
69
|
-
"claudeup-linux-x64": "6.
|
|
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-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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: "
|
|
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("
|
|
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
|
|
121
|
-
it("bumps global when project has a newer version (the
|
|
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: { "
|
|
123
|
+
installedPluginVersions: { "designer@magus": "1.0.0" },
|
|
124
124
|
});
|
|
125
125
|
await writeProjectSettings({
|
|
126
|
-
installedPluginVersions: { "
|
|
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: "
|
|
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["
|
|
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: { "
|
|
149
|
+
installedPluginVersions: { "designer@magus": "1.3.0" },
|
|
150
150
|
});
|
|
151
151
|
await writeProjectSettings({
|
|
152
|
-
installedPluginVersions: { "
|
|
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["
|
|
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: { "
|
|
301
|
+
installedPluginVersions: { "designer@magus": "2.0.0" },
|
|
302
302
|
});
|
|
303
303
|
await writeProjectSettings({
|
|
304
|
-
installedPluginVersions: { "
|
|
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["
|
|
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: { "
|
|
321
|
+
enabledPlugins: { "designer@magus": true, "dev@magus": true },
|
|
322
322
|
env: { SOME_VAR: "value" },
|
|
323
|
-
installedPluginVersions: { "
|
|
323
|
+
installedPluginVersions: { "designer@magus": "1.0.0" },
|
|
324
324
|
});
|
|
325
325
|
await writeProjectSettings({
|
|
326
|
-
installedPluginVersions: { "
|
|
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
|
-
"
|
|
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["
|
|
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: { "
|
|
348
|
+
installedPluginVersions: { "designer@magus": "not-a-version" },
|
|
349
349
|
});
|
|
350
350
|
await writeProjectSettings({
|
|
351
|
-
installedPluginVersions: { "
|
|
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: "
|
|
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["
|
|
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: { "
|
|
373
|
+
installedPluginVersions: { "designer@magus": "broken" },
|
|
374
374
|
});
|
|
375
375
|
await writeProjectSettings({
|
|
376
|
-
installedPluginVersions: { "
|
|
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["
|
|
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
|
+
});
|