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
|
@@ -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
|
+
}
|
package/src/services/manifest.ts
CHANGED
|
@@ -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
|
+
}
|