claudeup 6.3.2 → 6.4.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__/cli-live.test.ts +9 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- 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__/model-visuals.test.tsx +789 -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__/profile-materializer.test.ts +46 -0
- package/src/__tests__/resolver.test.ts +36 -0
- 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/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 -0
- package/src/data/models-presets.ts +281 -0
- package/src/data/predefined-profiles.ts +9 -0
- 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/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +11 -0
- 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/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/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.4.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.4.0",
|
|
68
|
+
"claudeup-darwin-x64": "6.4.0",
|
|
69
|
+
"claudeup-linux-x64": "6.4.0"
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -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
|
|
|
@@ -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
|
+
});
|
|
@@ -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
|
+
"seo:editor": { 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: "seo:editor" }));
|
|
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
|
+
});
|