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.
Files changed (58) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +36 -0
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/cli/doctor.ts +90 -0
  22. package/src/cli/hook.ts +129 -0
  23. package/src/cli/models.ts +214 -0
  24. package/src/cli/router.ts +12 -0
  25. package/src/data/gitignore-defaults.ts +4 -0
  26. package/src/data/models-presets.ts +281 -0
  27. package/src/data/predefined-profiles.ts +9 -0
  28. package/src/data/settings-catalog.ts +11 -4
  29. package/src/main.tsx +51 -82
  30. package/src/services/hook-registration.ts +218 -0
  31. package/src/services/manifest.ts +84 -0
  32. package/src/services/models-core.ts +628 -0
  33. package/src/services/models-manager.ts +606 -0
  34. package/src/services/profile-materializer.ts +17 -0
  35. package/src/services/resolver.ts +11 -0
  36. package/src/services/settings-file.ts +69 -0
  37. package/src/services/styles-manager.ts +23 -45
  38. package/src/services/symlink-manager.ts +57 -11
  39. package/src/tui.tsx +112 -0
  40. package/src/types/bun.d.ts +21 -0
  41. package/src/types/index.ts +14 -0
  42. package/src/ui/App.tsx +15 -3
  43. package/src/ui/adapters/modelsAdapter.ts +170 -0
  44. package/src/ui/components/TabBar.tsx +9 -4
  45. package/src/ui/components/layout/FooterHints.tsx +20 -3
  46. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  47. package/src/ui/components/primitives/MetaText.tsx +27 -1
  48. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  49. package/src/ui/renderers/modelVisuals.tsx +853 -0
  50. package/src/ui/renderers/skillRenderers.tsx +13 -3
  51. package/src/ui/renderers/styleRenderers.tsx +7 -3
  52. package/src/ui/screens/ModelsScreen.tsx +478 -0
  53. package/src/ui/screens/StylesScreen.tsx +8 -13
  54. package/src/ui/screens/index.ts +1 -0
  55. package/src/ui/state/reducer.ts +94 -0
  56. package/src/ui/state/types.ts +65 -2
  57. package/src/ui/theme-mode.ts +116 -0
  58. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Register `claudeup hook agent-model` as a `PreToolUse` hook on the Agent tool.
3
+ *
4
+ * The hook is what makes per-subagent routing exist at all: Claude Code has no
5
+ * per-agent model knob, and a PreToolUse hook rewriting the Agent tool's input
6
+ * is the only dynamic seam (measured — see `models-core.ts`). A config with no
7
+ * hook registered routes NOTHING, which is why `computeModelsStatus` reports
8
+ * `unhooked` ahead of any drift.
9
+ *
10
+ * Three rules, each earned:
11
+ *
12
+ * 1. **Identity is the command string.** Registration is idempotent because it
13
+ * looks for the command, not for a block shape. An existing `Agent` matcher
14
+ * block gets the command APPENDED rather than a second block created, so a
15
+ * user's own Agent hooks keep running alongside this one.
16
+ * 2. **The command is BARE, never an absolute path.** `claudeup upgrade`
17
+ * replaces the binary, and on npm the path carries a version directory — an
18
+ * absolute path recorded today is a dead path after the next upgrade, and
19
+ * the failure is invisible (a hook that cannot start is a hook that
20
+ * silently does not route).
21
+ * 3. **User scope, through `updateSettingsFile`.** NOT
22
+ * `readGlobalSettings`/`writeGlobalSettings`: those swallow a parse error
23
+ * and return `{}`, so a settings file a human is mid-edit in would be
24
+ * overwritten with one hook and nothing else. `updateSettingsFile` refuses
25
+ * a file it cannot parse.
26
+ */
27
+
28
+ import path from "node:path";
29
+ import { claudeConfigDirOrNull } from "../utils/config-dir.js";
30
+ import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
31
+
32
+ /** The command Claude Code runs. Bare — see rule 2 above. */
33
+ export const AGENT_MODEL_HOOK_COMMAND = "claudeup hook agent-model";
34
+
35
+ /** The tool whose input the hook rewrites. */
36
+ const MATCHER = "Agent";
37
+
38
+ /**
39
+ * Seconds. Generous for a process that reads one small JSON file and exits, and
40
+ * deliberately finite: Claude Code waits for this on every Agent call.
41
+ */
42
+ const TIMEOUT_SECONDS = 5;
43
+
44
+ interface HookCommand {
45
+ type: "command";
46
+ command: string;
47
+ timeout?: number;
48
+ }
49
+
50
+ const isRec = (v: unknown): v is Record<string, unknown> =>
51
+ typeof v === "object" && v !== null && !Array.isArray(v);
52
+
53
+ /** The `hooks.PreToolUse` array, or [] for any shape that is not one. */
54
+ function preToolUseBlocks(
55
+ settings: Record<string, unknown>,
56
+ ): Record<string, unknown>[] {
57
+ const hooks = settings.hooks;
58
+ if (!isRec(hooks)) return [];
59
+ const blocks = hooks.PreToolUse;
60
+ if (!Array.isArray(blocks)) return [];
61
+ return blocks.filter(isRec);
62
+ }
63
+
64
+ /** The command entries inside one matcher block. */
65
+ function commandsIn(block: Record<string, unknown>): Record<string, unknown>[] {
66
+ return Array.isArray(block.hooks) ? block.hooks.filter(isRec) : [];
67
+ }
68
+
69
+ function isOurCommand(entry: Record<string, unknown>): boolean {
70
+ return (
71
+ typeof entry.command === "string" &&
72
+ entry.command.trim() === AGENT_MODEL_HOOK_COMMAND
73
+ );
74
+ }
75
+
76
+ /** Is the agent-model hook already registered in this settings object? */
77
+ export function isHookRegistered(settings: Record<string, unknown>): boolean {
78
+ return preToolUseBlocks(settings).some((block) =>
79
+ commandsIn(block).some(isOurCommand),
80
+ );
81
+ }
82
+
83
+ function newCommandEntry(): HookCommand {
84
+ return {
85
+ type: "command",
86
+ command: AGENT_MODEL_HOOK_COMMAND,
87
+ timeout: TIMEOUT_SECONDS,
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Add the hook if it is missing. Returns a NEW settings object — the input is
93
+ * never mutated, so a caller can diff before deciding to write.
94
+ */
95
+ export function ensureHookRegistered(settings: Record<string, unknown>): {
96
+ settings: Record<string, unknown>;
97
+ changed: boolean;
98
+ } {
99
+ if (isHookRegistered(settings)) return { settings, changed: false };
100
+
101
+ const next = structuredClone(settings);
102
+ const hooks = isRec(next.hooks) ? next.hooks : {};
103
+ const blocks = Array.isArray(hooks.PreToolUse) ? [...hooks.PreToolUse] : [];
104
+
105
+ // Append into an existing Agent block rather than adding a second one: two
106
+ // blocks with the same matcher both fire, which works, but it makes the
107
+ // user's settings file progressively less readable and is indistinguishable
108
+ // from a bug when they open it.
109
+ const existing = blocks.findIndex(
110
+ (b) => isRec(b) && b.matcher === MATCHER && Array.isArray(b.hooks),
111
+ );
112
+ if (existing >= 0) {
113
+ const block = blocks[existing] as Record<string, unknown>;
114
+ blocks[existing] = {
115
+ ...block,
116
+ hooks: [...(block.hooks as unknown[]), newCommandEntry()],
117
+ };
118
+ } else {
119
+ blocks.push({ matcher: MATCHER, hooks: [newCommandEntry()] });
120
+ }
121
+
122
+ next.hooks = { ...hooks, PreToolUse: blocks };
123
+ return { settings: next, changed: true };
124
+ }
125
+
126
+ /**
127
+ * Remove every registration of the hook. An Agent block left with no commands
128
+ * is dropped, so uninstalling leaves no trace; other blocks and other commands
129
+ * inside a shared Agent block are untouched.
130
+ */
131
+ export function removeHookRegistration(settings: Record<string, unknown>): {
132
+ settings: Record<string, unknown>;
133
+ changed: boolean;
134
+ } {
135
+ if (!isHookRegistered(settings)) return { settings, changed: false };
136
+
137
+ const next = structuredClone(settings);
138
+ const hooks = next.hooks as Record<string, unknown>;
139
+ const blocks: unknown[] = [];
140
+ for (const block of hooks.PreToolUse as unknown[]) {
141
+ if (!isRec(block) || !Array.isArray(block.hooks)) {
142
+ blocks.push(block);
143
+ continue;
144
+ }
145
+ const kept = block.hooks.filter((e) => !(isRec(e) && isOurCommand(e)));
146
+ if (kept.length === 0 && block.matcher === MATCHER) continue;
147
+ blocks.push({ ...block, hooks: kept });
148
+ }
149
+
150
+ if (blocks.length > 0) {
151
+ hooks.PreToolUse = blocks;
152
+ } else {
153
+ // biome-ignore lint/performance/noDelete: an empty PreToolUse array is noise in a file people read
154
+ delete hooks.PreToolUse;
155
+ }
156
+ if (Object.keys(hooks).length === 0) {
157
+ // biome-ignore lint/performance/noDelete: same reason — leave no empty husk
158
+ delete next.hooks;
159
+ }
160
+ return { settings: next, changed: true };
161
+ }
162
+
163
+ /**
164
+ * The user-scope settings file this writes to.
165
+ *
166
+ * `claudeConfigDirOrNull` honours `CLAUDE_CONFIG_DIR` and returns null under
167
+ * `bun test` when no test has chosen a directory — which is a refusal, not a
168
+ * fallback: writing a hook into the operator's real `~/.claude/settings.json`
169
+ * from a test run is exactly the damage that guard exists to prevent.
170
+ */
171
+ function userSettingsPath(configDir?: string): string {
172
+ const dir = configDir ?? claudeConfigDirOrNull();
173
+ if (!dir) {
174
+ throw new Error(
175
+ "No Claude config directory resolved. Set CLAUDE_CONFIG_DIR (a test must isolate it) before registering the agent-model hook.",
176
+ );
177
+ }
178
+ return path.join(dir, "settings.json");
179
+ }
180
+
181
+ /**
182
+ * Register the hook at user scope, once.
183
+ *
184
+ * User scope on purpose: routing is configured per project, but the hook that
185
+ * applies it has to be running in whatever session opens that project, and a
186
+ * project-scope hook would only exist for projects claudeup had already
187
+ * touched. The hook itself is a no-op for a project with no `models.json`, so
188
+ * one global registration costs nothing everywhere else.
189
+ */
190
+ export async function registerAgentModelHook(
191
+ opts: { configDir?: string } = {},
192
+ ): Promise<"registered" | "already"> {
193
+ const settingsPath = userSettingsPath(opts.configDir);
194
+ // Read first so an unchanged file is not rewritten (and its mtime not
195
+ // touched) on every `models use`.
196
+ if (isHookRegistered(await readSettingsFile(settingsPath))) return "already";
197
+
198
+ await updateSettingsFile(settingsPath, (settings) => {
199
+ return ensureHookRegistered(settings).settings;
200
+ });
201
+ return "registered";
202
+ }
203
+
204
+ /** Is the hook registered in the user-scope settings file? */
205
+ export async function isAgentModelHookRegistered(
206
+ opts: { configDir?: string } = {},
207
+ ): Promise<boolean> {
208
+ try {
209
+ return isHookRegistered(
210
+ await readSettingsFile(userSettingsPath(opts.configDir)),
211
+ );
212
+ } catch {
213
+ // No config dir, or a settings file we cannot parse. Either way we cannot
214
+ // claim it is registered — and the caller (doctor / status) reports that
215
+ // as "not routed", which is the honest answer.
216
+ return false;
217
+ }
218
+ }
@@ -10,6 +10,7 @@
10
10
  import path from "node:path";
11
11
  import fs from "fs-extra";
12
12
  import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
13
+ import { validateModelsConfig } from "./models-core.js";
13
14
 
14
15
  const PROFILES_FILE = "profiles.json";
15
16
 
@@ -160,7 +161,90 @@ export function validateManifest(
160
161
  }
161
162
  });
162
163
  }
164
+ // Model routing is the one field here whose bad value does not degrade:
165
+ // MEASURED (benches/agent-model-routing, `bogus-model` 5/5), a model the
166
+ // Agent tool does not accept refuses the spawn outright, so EVERY subagent
167
+ // dispatch fails for everyone who pulled the manifest. This is the gate
168
+ // that stops a hand-edited entry reaching a session.
169
+ if (entry.models !== undefined) {
170
+ for (const e of validateModelsConfig(entry.models)) {
171
+ errors.push({
172
+ path: e.path ? `${base}.models.${e.path}` : `${base}.models`,
173
+ message: e.message,
174
+ });
175
+ }
176
+ }
163
177
  }
164
178
 
165
179
  return errors;
166
180
  }
181
+
182
+ /**
183
+ * Read/modify/write ONE profile entry in the committed manifest.
184
+ *
185
+ * Returns false when there is nothing to record against (no profile named, no
186
+ * manifest file, or no entry for that profile) — all three are normal states,
187
+ * and `mutate` is not called.
188
+ *
189
+ * THROWS when the manifest exists and cannot be read as JSON. `readManifest`
190
+ * degrades a corrupt file to an empty manifest, which here would mean silently
191
+ * recording nothing and reporting success — the caller has just written a live
192
+ * setting the next `claudeup install` will revert, and would never learn.
193
+ */
194
+ export async function updateProfileEntry(
195
+ projectPath: string,
196
+ profile: string | null,
197
+ mutate: (entry: ProfileManifestEntry) => void,
198
+ ): Promise<boolean> {
199
+ if (!profile) return false;
200
+ const filePath = getManifestPath(projectPath);
201
+ if (!(await fs.pathExists(filePath))) return false;
202
+
203
+ // Read directly rather than through readManifest: its `catch` is exactly
204
+ // what turns a corrupt file into a silent no-op.
205
+ let raw: unknown;
206
+ try {
207
+ raw = await fs.readJson(filePath);
208
+ } catch (error) {
209
+ throw new Error(
210
+ `${filePath} is not valid JSON, refusing to write to it: ${String(error)}`,
211
+ );
212
+ }
213
+
214
+ const manifest = migrateToV2(raw);
215
+ const entry = manifest.profiles[profile];
216
+ if (!entry) return false;
217
+
218
+ mutate(entry);
219
+ entry.updatedAt = new Date().toISOString();
220
+ await writeManifest(manifest, projectPath);
221
+ return true;
222
+ }
223
+
224
+ /**
225
+ * Merge `patch` into `profiles.<profile>.settings`, optionally removing the
226
+ * keys named in `unset`.
227
+ *
228
+ * Why this exists: `.claude/settings.json` is a symlink into the active
229
+ * profile's materialized directory, so a live write lands in the right place —
230
+ * but `materializeProfile` REWRITES that file from the manifest on every
231
+ * `claudeup install`, so the live write alone silently regresses. The manifest
232
+ * is the durable half of every settings write claudeup makes.
233
+ */
234
+ export async function mergeManifestSettings(
235
+ projectPath: string,
236
+ profile: string | null,
237
+ patch: Record<string, unknown>,
238
+ opts: { unset?: readonly string[] } = {},
239
+ ): Promise<boolean> {
240
+ return updateProfileEntry(projectPath, profile, (entry) => {
241
+ const settings = { ...(entry.settings ?? {}), ...patch };
242
+ for (const key of opts.unset ?? []) {
243
+ // The key must be ABSENT, not present-and-undefined: an `= undefined`
244
+ // assignment still answers `in` checks, so the next call would read it
245
+ // as already handled and skip work it still owes.
246
+ delete settings[key];
247
+ }
248
+ entry.settings = settings;
249
+ });
250
+ }