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/src/main.tsx
CHANGED
|
@@ -1,29 +1,68 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { RGBA, createCliRenderer } from "@opentui/core";
|
|
4
|
-
import { createRoot } from "@opentui/react";
|
|
5
3
|
// Static import so `bun build --compile` embeds package.json into the binary;
|
|
6
4
|
// a dynamic require("../package.json") is not resolvable inside the bunfs root.
|
|
7
5
|
import pkg from "../package.json";
|
|
8
|
-
import { route } from "./cli/router.js";
|
|
9
6
|
import { loadProjectDotenv } from "./services/dotenv.js";
|
|
10
|
-
import { App } from "./ui/App.js";
|
|
11
|
-
import { setThemeMode } from "./ui/theme-mode.js";
|
|
12
|
-
import { resolveTheme, snapshotThemeEnv } from "./ui/theme-resolve.js";
|
|
13
7
|
|
|
14
8
|
export const VERSION = (pkg as { version: string }).version;
|
|
15
9
|
|
|
16
10
|
// Note: OpenTUI renderer handles alternate screen buffer automatically
|
|
17
11
|
// No need for manual ANSI escape codes
|
|
12
|
+
//
|
|
13
|
+
// NOTHING in this file may statically import @opentui/* or ./ui/*. `claudeup
|
|
14
|
+
// hook …` runs on every matching Claude Code tool call, and a static import
|
|
15
|
+
// would load the whole TUI module graph before the hook could answer. The TUI
|
|
16
|
+
// (./tui.js), the router (./cli/router.js, which reaches @opentui through
|
|
17
|
+
// cli/update.ts → ui/theme.ts) and the hook itself are all imported on demand,
|
|
18
|
+
// after the branch that decides which one is needed.
|
|
19
|
+
// Pinned by __tests__/hook-import-policy.test.ts.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* `hook` as the first non-theme token → the argv the hook command should see.
|
|
23
|
+
* Anything else → null.
|
|
24
|
+
*
|
|
25
|
+
* A leading `--theme light|dark` (or `--theme=…`) pair is skipped so this
|
|
26
|
+
* matches the router's own dispatch, which drops the theme tokens before
|
|
27
|
+
* looking at the command. Deliberately NOT `parseThemeFlag` from the router:
|
|
28
|
+
* importing that module is the cost this branch exists to avoid, and a hook
|
|
29
|
+
* has no use for a validated theme.
|
|
30
|
+
*/
|
|
31
|
+
function hookArgv(argv: string[]): string[] | null {
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < argv.length) {
|
|
34
|
+
const token = argv[i] as string;
|
|
35
|
+
if (token === "--theme") {
|
|
36
|
+
i += 2;
|
|
37
|
+
} else if (token.startsWith("--theme=")) {
|
|
38
|
+
i += 1;
|
|
39
|
+
} else {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return argv[i] === "hook" ? argv.slice(i + 1) : null;
|
|
44
|
+
}
|
|
18
45
|
|
|
19
46
|
async function main(): Promise<void> {
|
|
20
47
|
// FIRST, before anything can touch process.env: the theme is read from the
|
|
21
48
|
// environment claudeup was started with, never from a project's .env. The
|
|
22
49
|
// dotenv load below merges into process.env, so the only thing separating
|
|
23
50
|
// the two is that this line runs before it (pinned by theme-env.test.ts).
|
|
51
|
+
const { snapshotThemeEnv } = await import("./ui/theme-resolve.js");
|
|
24
52
|
const themeEnv = snapshotThemeEnv();
|
|
25
53
|
const args = process.argv.slice(2);
|
|
26
54
|
|
|
55
|
+
// Hooks answer here and go no further. This runs BEFORE loadProjectDotenv()
|
|
56
|
+
// on purpose: a hook must never open a project's .env, because that file is
|
|
57
|
+
// often a symlink to a 1Password FIFO and reading one BLOCKS until a human
|
|
58
|
+
// approves it — on every tool call. The hook needs no .env, so it never
|
|
59
|
+
// touches one.
|
|
60
|
+
const hookRest = hookArgv(args);
|
|
61
|
+
if (hookRest !== null) {
|
|
62
|
+
const { runHookCommand } = await import("./cli/hook.js");
|
|
63
|
+
process.exit(await runHookCommand(hookRest));
|
|
64
|
+
}
|
|
65
|
+
|
|
27
66
|
// Load the project's .env ourselves. Bun's standalone autoload is compiled OFF
|
|
28
67
|
// (scripts/build-binaries.ts) because it killed claudeup outright wherever
|
|
29
68
|
// .env was a symlink to a FIFO — the shape 1Password uses — exiting 1 with no
|
|
@@ -44,6 +83,7 @@ async function main(): Promise<void> {
|
|
|
44
83
|
// Dispatch non-interactive subcommands (claude, update, install, …) and
|
|
45
84
|
// top-level flags (--version/--help). A bare invocation falls through to
|
|
46
85
|
// the interactive TUI below.
|
|
86
|
+
const { route } = await import("./cli/router.js");
|
|
47
87
|
const outcome = await route(args, VERSION);
|
|
48
88
|
if (outcome.handled) {
|
|
49
89
|
process.exit(outcome.exitCode ?? 0);
|
|
@@ -54,85 +94,14 @@ async function main(): Promise<void> {
|
|
|
54
94
|
const isInteractive =
|
|
55
95
|
process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
56
96
|
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
// element now names an adaptive colour explicitly — see src/ui/theme.ts.
|
|
63
|
-
// The mouse is ON, everywhere — it carries real features now: the wheel
|
|
64
|
-
// scrolls the pane under the cursor, and a drag selects text from ONE pane
|
|
65
|
-
// at a time (terminal-native selection is a screen-wide rectangle that
|
|
66
|
-
// grabs both columns), OSC 52-copied on release below.
|
|
67
|
-
//
|
|
68
|
-
// This deliberately includes tmux. Two earlier revisions got this wrong in
|
|
69
|
-
// opposite directions: one disabled the mouse everywhere on the theory that
|
|
70
|
-
// capture breaks tmux-level pane clicking, the next kept it off only inside
|
|
71
|
-
// tmux. Both dated from when claudeup had no mouse features, so disabling
|
|
72
|
-
// cost nothing. MEASURED on a heavily tmux'd machine: tmux forwards mouse
|
|
73
|
-
// to a pane application that asks for it (htop in the same session took
|
|
74
|
-
// clicks and wheel fine while claudeup sat inert), and tmux's own `mouse`
|
|
75
|
-
// option keeps governing pane management. Terminal-native selection stays
|
|
76
|
-
// reachable via Shift+drag in most terminals.
|
|
77
|
-
//
|
|
78
|
-
// enableMouseMovement (mode-1003 "report all motion") stays off — selection
|
|
79
|
-
// needs only button-held drag events, which button reporting delivers.
|
|
80
|
-
const renderer = await createCliRenderer({
|
|
81
|
-
backgroundColor: RGBA.defaultBackground(),
|
|
82
|
-
useMouse: true,
|
|
83
|
-
enableMouseMovement: false,
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
// Mouse selection → system clipboard, on release. OSC 52 survives SSH;
|
|
87
|
-
// terminals that block it simply ignore the sequence. Never fires where the
|
|
88
|
-
// mouse is off, so the tmux path pays nothing.
|
|
89
|
-
renderer.on("selection", (selection: { getSelectedText(): string }) => {
|
|
90
|
-
const text = selection?.getSelectedText() ?? "";
|
|
91
|
-
if (text.length > 0) renderer.copyToClipboardOSC52(text);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// Resolve light vs dark once: --theme, CLAUDEUP_THEME, TERM_THEME, the OSC
|
|
95
|
-
// probe, COLORFGBG, dark — see src/ui/theme-resolve.ts. Only the disabled-row
|
|
96
|
-
// tint and the chips need this (src/ui/theme-mode.ts explains why that one
|
|
97
|
-
// case cannot be solved the way the rest of the palette is).
|
|
98
|
-
//
|
|
99
|
-
// The renderer sends OSC 10/11 at creation regardless: @opentui/core 0.1.107
|
|
100
|
-
// has no option to suppress it. So the rule "TERM_THEME skips the probe" is
|
|
101
|
-
// satisfied by NOT AWAITING — when steps 1-3 answer, `probe` is never called,
|
|
102
|
-
// `waitForThemeMode(250)` never runs, first paint is not delayed, and
|
|
103
|
-
// `renderer.themeMode` is never read anywhere in claudeup. Any late reply the
|
|
104
|
-
// terminal sends is consumed by OpenTUI's own handleSequence and ignored here.
|
|
105
|
-
const { mode } = await resolveTheme({
|
|
106
|
-
flag: outcome.theme,
|
|
97
|
+
// Last possible moment: this is the import that pulls in @opentui and the
|
|
98
|
+
// whole ui/ tree. Everything above runs without them.
|
|
99
|
+
const { startTui } = await import("./tui.js");
|
|
100
|
+
await startTui({
|
|
101
|
+
theme: outcome.theme,
|
|
107
102
|
env: themeEnv,
|
|
108
103
|
isInteractive,
|
|
109
|
-
probe: () => renderer.waitForThemeMode(250).catch(() => null),
|
|
110
104
|
});
|
|
111
|
-
setThemeMode(mode);
|
|
112
|
-
|
|
113
|
-
const root = createRoot(renderer);
|
|
114
|
-
|
|
115
|
-
// Cleanup function to restore terminal
|
|
116
|
-
const cleanup = () => {
|
|
117
|
-
root.unmount();
|
|
118
|
-
renderer.destroy(); // CRITICAL: Never use process.exit() directly
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// Handle cleanup on exit signals
|
|
122
|
-
const handleExit = () => {
|
|
123
|
-
cleanup();
|
|
124
|
-
// Exit after cleanup completes
|
|
125
|
-
process.exit(0);
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
process.on("SIGINT", handleExit);
|
|
129
|
-
process.on("SIGTERM", handleExit);
|
|
130
|
-
|
|
131
|
-
// Render the OpenTUI app with exit handler
|
|
132
|
-
root.render(<App onExit={handleExit} />);
|
|
133
|
-
|
|
134
|
-
// Wait indefinitely (app runs until user exits)
|
|
135
|
-
await new Promise(() => {});
|
|
136
105
|
}
|
|
137
106
|
|
|
138
107
|
main().catch((error) => {
|
|
@@ -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
|
+
}
|