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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-notice.test.ts +3 -3
  3. package/src/__tests__/cli-live.test.ts +9 -2
  4. package/src/__tests__/cli-update-view.test.ts +2 -2
  5. package/src/__tests__/footer-hints.test.ts +40 -0
  6. package/src/__tests__/gap-fill-versions.test.ts +24 -24
  7. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  8. package/src/__tests__/hook-import-policy.test.ts +90 -0
  9. package/src/__tests__/hook-process.test.ts +256 -0
  10. package/src/__tests__/hook-registration.test.ts +224 -0
  11. package/src/__tests__/manifest.test.ts +134 -0
  12. package/src/__tests__/marketplace-badge.test.ts +1 -1
  13. package/src/__tests__/marketplaces.test.ts +0 -1
  14. package/src/__tests__/model-visuals.test.tsx +793 -0
  15. package/src/__tests__/models-adapter.test.ts +317 -0
  16. package/src/__tests__/models-cli.test.ts +173 -0
  17. package/src/__tests__/models-core.test.ts +640 -0
  18. package/src/__tests__/models-manager.test.ts +497 -0
  19. package/src/__tests__/models-screen-state.test.ts +259 -0
  20. package/src/__tests__/moved-marketplace.test.ts +7 -8
  21. package/src/__tests__/plugin-contents.test.ts +1 -1
  22. package/src/__tests__/profile-adopt.test.ts +1 -1
  23. package/src/__tests__/profile-materializer.test.ts +48 -2
  24. package/src/__tests__/resolver.test.ts +43 -6
  25. package/src/__tests__/settings-file.test.ts +179 -0
  26. package/src/__tests__/symlink-manager.test.ts +65 -1
  27. package/src/__tests__/tabbar-layout.test.ts +40 -2
  28. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  29. package/src/__tests__/version-snapshot.test.ts +4 -4
  30. package/src/cli/doctor.ts +90 -0
  31. package/src/cli/hook.ts +129 -0
  32. package/src/cli/models.ts +214 -0
  33. package/src/cli/router.ts +12 -0
  34. package/src/data/gitignore-defaults.ts +4 -1
  35. package/src/data/gitignore-reasons.ts +0 -4
  36. package/src/data/marketplaces.ts +1 -15
  37. package/src/data/models-presets.ts +270 -0
  38. package/src/data/predefined-profiles.ts +12 -21
  39. package/src/data/settings-catalog.ts +11 -4
  40. package/src/main.tsx +51 -82
  41. package/src/services/hook-registration.ts +218 -0
  42. package/src/services/manifest.ts +84 -0
  43. package/src/services/models-core.ts +628 -0
  44. package/src/services/models-manager.ts +606 -0
  45. package/src/services/plugin-manager.ts +2 -3
  46. package/src/services/profile-materializer.ts +17 -0
  47. package/src/services/resolver.ts +13 -2
  48. package/src/services/settings-file.ts +69 -0
  49. package/src/services/styles-manager.ts +23 -45
  50. package/src/services/symlink-manager.ts +57 -11
  51. package/src/tui.tsx +112 -0
  52. package/src/types/bun.d.ts +21 -0
  53. package/src/types/index.ts +14 -0
  54. package/src/ui/App.tsx +15 -3
  55. package/src/ui/adapters/modelsAdapter.ts +170 -0
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/TabBar.tsx +9 -4
  58. package/src/ui/components/layout/FooterHints.tsx +20 -3
  59. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  60. package/src/ui/components/primitives/MetaText.tsx +27 -1
  61. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  62. package/src/ui/renderers/modelVisuals.tsx +853 -0
  63. package/src/ui/renderers/skillRenderers.tsx +13 -3
  64. package/src/ui/renderers/styleRenderers.tsx +7 -3
  65. package/src/ui/screens/ModelsScreen.tsx +478 -0
  66. package/src/ui/screens/PluginsScreen.tsx +1 -1
  67. package/src/ui/screens/StylesScreen.tsx +8 -13
  68. package/src/ui/screens/index.ts +1 -0
  69. package/src/ui/state/reducer.ts +94 -0
  70. package/src/ui/state/types.ts +65 -2
  71. package/src/ui/theme-mode.ts +116 -0
  72. 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
- // Create OpenTUI renderer (handles alternate screen buffer automatically).
58
- //
59
- // The background is the terminal's own, not a colour of ours: painting an
60
- // absolute fill would box the UI into whatever theme we guessed. OpenTUI's
61
- // built-in default for unstyled cells is truecolor white, which is why every
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
+ }
@@ -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
+ }