claudeup 6.3.0 → 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 (59) 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 +38 -2
  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/__tests__/version-snapshot.test.ts +2 -4
  22. package/src/cli/doctor.ts +90 -0
  23. package/src/cli/hook.ts +129 -0
  24. package/src/cli/models.ts +214 -0
  25. package/src/cli/router.ts +12 -0
  26. package/src/data/gitignore-defaults.ts +4 -0
  27. package/src/data/models-presets.ts +281 -0
  28. package/src/data/predefined-profiles.ts +16 -7
  29. package/src/data/settings-catalog.ts +11 -4
  30. package/src/main.tsx +51 -82
  31. package/src/services/hook-registration.ts +218 -0
  32. package/src/services/manifest.ts +84 -0
  33. package/src/services/models-core.ts +628 -0
  34. package/src/services/models-manager.ts +606 -0
  35. package/src/services/profile-materializer.ts +17 -0
  36. package/src/services/resolver.ts +11 -0
  37. package/src/services/settings-file.ts +69 -0
  38. package/src/services/styles-manager.ts +23 -45
  39. package/src/services/symlink-manager.ts +57 -11
  40. package/src/tui.tsx +112 -0
  41. package/src/types/bun.d.ts +21 -0
  42. package/src/types/index.ts +14 -0
  43. package/src/ui/App.tsx +15 -3
  44. package/src/ui/adapters/modelsAdapter.ts +170 -0
  45. package/src/ui/components/TabBar.tsx +9 -4
  46. package/src/ui/components/layout/FooterHints.tsx +20 -3
  47. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  48. package/src/ui/components/primitives/MetaText.tsx +27 -1
  49. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  50. package/src/ui/renderers/modelVisuals.tsx +853 -0
  51. package/src/ui/renderers/skillRenderers.tsx +13 -3
  52. package/src/ui/renderers/styleRenderers.tsx +7 -3
  53. package/src/ui/screens/ModelsScreen.tsx +478 -0
  54. package/src/ui/screens/StylesScreen.tsx +8 -13
  55. package/src/ui/screens/index.ts +1 -0
  56. package/src/ui/state/reducer.ts +94 -0
  57. package/src/ui/state/types.ts +65 -2
  58. package/src/ui/theme-mode.ts +116 -0
  59. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Built-in presets and the shared agent → tier table.
3
+ *
4
+ * Hand-written constants rather than a glob over data files, for the same reason
5
+ * `src/data/styles/index.ts` hand-lists its embedded presets: a glob cannot resolve at
6
+ * `bun build --compile` time, and this module has to survive into the compiled binary — the
7
+ * hook path reads a project's own `models.json`, but `claudeup models list` and the TUI read
8
+ * these.
9
+ *
10
+ * Tiers name what a subagent is FOR, on one axis: `smart`, `normal`, `cheap`, plus `main` for
11
+ * the orchestrator. Which model serves each is the PRESET's call, and `smart` is not always
12
+ * its largest spend: `fable-advisor` sends the judgement calls to a different model (fable at
13
+ * `medium`) rather than a harder-working one. What holds everywhere is the other half: the
14
+ * thread dispatching the work is never strictly the biggest spend in the preset.
15
+ */
16
+ import type { Grade, ModelsConfig } from "../services/models-core.js";
17
+
18
+ /**
19
+ * Which tier each subagent gets, shared by every preset.
20
+ *
21
+ * Keys are `subagent_type` strings exactly as the Agent tool receives them: bare names for
22
+ * Claude Code's built-ins, `plugin:name` for plugin agents. A name absent from this table
23
+ * falls to the preset's `fallback`, so a newly installed plugin routes sensibly on day one
24
+ * instead of silently inheriting the orchestrator's model.
25
+ */
26
+ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
27
+ // `smart` is for JUDGEMENT — work where the agent decides something a person would
28
+ // argue about, and where being wrong is expensive to undo.
29
+ Plan: "smart",
30
+ "dev:architect": "smart",
31
+ "dev:reviewer": "smart",
32
+ "dev:debugger": "smart",
33
+ "multimodel:deep-analyst": "smart",
34
+
35
+ // `normal`: transforms material it is GIVEN. Real work, bounded judgement.
36
+ //
37
+ // `spec-writer` reads an interview log and writes it up as a spec — the decisions were
38
+ // made during the interview, and this turns answers into structure. `synthesizer` merges
39
+ // reviews it is handed into one report against thresholds it is handed. Neither decides
40
+ // what is true; both were on `smart` because "writes an important document" got confused
41
+ // with "makes an expensive judgement".
42
+ "dev:spec-writer": "normal",
43
+ "dev:synthesizer": "normal",
44
+
45
+ // Reading a lot is not analysing.
46
+ //
47
+ // `detective` is read-only: it locates implementations, traces a path and reports
48
+ // file:line. That is high-volume retrieval with light inference — the same shape as
49
+ // `Explore`, with more structure in the output. It sat on `smart` because "investigates"
50
+ // sounds like thinking; what it actually does is look.
51
+ "code-analysis:detective": "cheap",
52
+
53
+ // Search, transcription and bookkeeping: bounded, mechanical, high-volume.
54
+ Explore: "cheap",
55
+ "dev:scribe": "cheap",
56
+ "dev:stack-detector": "cheap",
57
+ "dev:docs": "cheap",
58
+ "terminal:tui-navigator": "cheap",
59
+ "gtd:gtd-reviewer": "cheap",
60
+ "statusline-setup": "cheap",
61
+ };
62
+
63
+ /**
64
+ * The three workflows the `dev` plugin actually runs, and the agents each one dispatches.
65
+ *
66
+ * A single "17 agents" bar counts every agent claudeup has ever heard of, weighted equally —
67
+ * which is not how a session spends anything. Nobody runs all seventeen; they run ONE of
68
+ * these, and what it costs depends on which handful it reaches for. `/dev:investigate`
69
+ * touching two cheap agents and `/dev:dev` touching five across three tiers are different
70
+ * answers to "what will this preset cost me", and the lump sum shows neither.
71
+ *
72
+ * Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
73
+ * also name skills (`dev:context-detection`, `code-analysis:investigate`) which are not
74
+ * dispatched through the Agent tool and so are not routed at all.
75
+ */
76
+ export const WORKFLOWS: { name: string; agents: string[] }[] = [
77
+ {
78
+ name: "dev",
79
+ agents: [
80
+ "dev:stack-detector",
81
+ "dev:architect",
82
+ "dev:developer",
83
+ "dev:test-architect",
84
+ "dev:reviewer",
85
+ ],
86
+ },
87
+ {
88
+ name: "debug",
89
+ agents: [
90
+ "dev:stack-detector",
91
+ "code-analysis:detective",
92
+ "dev:debugger",
93
+ "dev:developer",
94
+ ],
95
+ },
96
+ {
97
+ name: "investigate",
98
+ agents: ["code-analysis:detective", "dev:researcher"],
99
+ },
100
+ ];
101
+
102
+ /**
103
+ * Agents whose model is a CAPABILITY choice, not a spend choice.
104
+ *
105
+ * The three tiers answer "how much model", and that is the wrong question for an agent that
106
+ * reads images. `designer:design-review` compares a rendered screen against a reference and
107
+ * `designer:ui` audits a screenshot for usability: what they need is the model that SEES
108
+ * best, which is not a point on a cheap-to-smart line and does not move when the preset
109
+ * changes.
110
+ *
111
+ * They are deliberately absent from the tier table above and from this map until the model
112
+ * is chosen from the live catalogue rather than from memory. Absent means they fall to the
113
+ * preset's `fallback`, which is a defensible default and an honest one; pinning them to a
114
+ * model id guessed here is neither.
115
+ */
116
+ export const VISION_AGENTS = ["designer:design-review", "designer:ui"] as const;
117
+
118
+ /**
119
+ * `seo:editor` is the one agent in this marketplace whose own frontmatter sets a model.
120
+ * Routing it to `inherit` keeps that authoring decision visible and working instead of
121
+ * silently overriding it — per-invocation beats frontmatter, so without this the author's
122
+ * choice would vanish with no error.
123
+ */
124
+ const FRONTMATTER_RESPECTED: Record<string, { model: "inherit" }> = {
125
+ "seo:editor": { model: "inherit" },
126
+ };
127
+
128
+ function preset(
129
+ name: string,
130
+ main: ModelsConfig["main"],
131
+ grades: ModelsConfig["grades"],
132
+ ): ModelsConfig {
133
+ return {
134
+ version: 1,
135
+ preset: name,
136
+ main,
137
+ grades,
138
+ agents: { ...DEFAULT_AGENT_GRADES, ...FRONTMATTER_RESPECTED },
139
+ fallback: "normal",
140
+ };
141
+ }
142
+
143
+ /**
144
+ * What each preset is CALLED on screen.
145
+ *
146
+ * The id (`fable-advisor`) is the machine name: it is what `models use` takes, what lands in
147
+ * the committed config, and what must never change. The label is what a person reads, and it
148
+ * says which model leads and which one helps — the thing you actually choose between. Kept
149
+ * beside the presets rather than in the UI so the CLI and the TUI cannot disagree.
150
+ */
151
+ export const PRESET_LABELS: Record<string, string> = {
152
+ "fable-advisor": "Opus with Fable help",
153
+ "fable-lead": "Fable main",
154
+ "fable-top": "Fable top",
155
+ "opus-lead": "Opus main",
156
+ "sonnet-economy": "Sonnet",
157
+ };
158
+
159
+ /** The screen name for a preset id, falling back to the id for a hand-edited config. */
160
+ export function presetLabel(id: string): string {
161
+ return PRESET_LABELS[id] ?? id;
162
+ }
163
+
164
+ /**
165
+ * The shipped presets.
166
+ *
167
+ * Effort is set per seat and reaches subagents through `modelSettings` — measured by
168
+ * `benches/agent-model-routing/` (AMR-1), which found alias keys honoured and a routed
169
+ * subagent running at its model's effort while the main thread kept its own.
170
+ *
171
+ * ## Effort belongs to the MODEL, so a preset gets one value per model
172
+ *
173
+ * `modelSettings` is keyed by model. Two seats naming the same model therefore cannot carry
174
+ * different efforts, and the validator rejects that rather than letting one silently win.
175
+ * That is the only cross-seat rule here: **within one preset, a model appears at exactly one
176
+ * effort.** Which effort is a per-preset choice — `fable-lead` runs fable at `medium` and
177
+ * `fable-top` runs it at `xhigh`, and that difference is the whole distinction between them.
178
+ *
179
+ * Presets never collide with each other, because only one is ever applied to a project.
180
+ *
181
+ * ## The levelling these presets apply
182
+ *
183
+ * Each one fixes every model it names at a single effort, which is what makes it writable:
184
+ *
185
+ * opus xhigh wherever it appears
186
+ * fable medium in `fable-lead`, xhigh in `fable-top`
187
+ * sonnet high in the `cheap` seat, xhigh when it holds the whole preset
188
+ * haiku not used
189
+ *
190
+ * A tier therefore differs from its neighbour by MODEL, not by a throttle on one model. That
191
+ * is not a style choice: a ramp like "fable xhigh on `smart`, fable low on `cheap`" is exactly
192
+ * what the `modelSettings` keying cannot express.
193
+ */
194
+ export const BUILT_IN_PRESETS: ModelsConfig[] = [
195
+ // The default: opus runs everything except the judgement calls, which go to fable.
196
+ //
197
+ // Opus takes `main` and `normal` at `xhigh`, fable takes `smart` at its ordinary `medium`,
198
+ // and sonnet picks up the cheap seat at `high`. The id says what fable DOES here — it
199
+ // advises on the hard work rather than running the thread.
200
+ preset(
201
+ "fable-advisor",
202
+ { model: "opus", effort: "xhigh" },
203
+ {
204
+ smart: { model: "fable", effort: "medium" },
205
+ normal: { model: "opus", effort: "xhigh" },
206
+ cheap: { model: "sonnet", effort: "high" },
207
+ },
208
+ ),
209
+
210
+ // Fable runs everything it can, at its ordinary `medium`, with sonnet on the cheap seat.
211
+ //
212
+ // "Fable on the main orchestrator" — the request, literally, extended to the tiers it
213
+ // dispatches. Paired with `fable-top` below: the same model on `main` and `smart`, one
214
+ // effort apart.
215
+ preset(
216
+ "fable-lead",
217
+ { model: "fable", effort: "medium" },
218
+ {
219
+ smart: { model: "fable", effort: "medium" },
220
+ normal: { model: "fable", effort: "medium" },
221
+ cheap: { model: "sonnet", effort: "high" },
222
+ },
223
+ ),
224
+
225
+ // Fable at full stretch on the thread and the judgement calls; opus does the building.
226
+ //
227
+ // `fable-lead` runs fable at `medium` in three seats; this runs it at `xhigh` in two and
228
+ // hands `normal` — the highest-volume tier, since it is also the `fallback` — to opus.
229
+ // The difference between the two presets is fable's effort, which is why it has to BE two
230
+ // presets rather than one with a ramp.
231
+ preset(
232
+ "fable-top",
233
+ { model: "fable", effort: "xhigh" },
234
+ {
235
+ smart: { model: "fable", effort: "xhigh" },
236
+ normal: { model: "opus", effort: "xhigh" },
237
+ cheap: { model: "sonnet", effort: "high" },
238
+ },
239
+ ),
240
+
241
+ // Opus everywhere it is reached at all, at `xhigh`; sonnet only on the cheap seat.
242
+ //
243
+ // `main`, `smart` and `normal` are one spend decision wearing three names. That is legal
244
+ // precisely because they agree on effort, and it is the point of the preset rather than a
245
+ // compromise: the only thing NOT on opus is the searching and transcription.
246
+ preset(
247
+ "opus-lead",
248
+ { model: "opus", effort: "xhigh" },
249
+ {
250
+ smart: { model: "opus", effort: "xhigh" },
251
+ normal: { model: "opus", effort: "xhigh" },
252
+ cheap: { model: "sonnet", effort: "high" },
253
+ },
254
+ ),
255
+
256
+ // Sonnet, at `xhigh`, in all four seats.
257
+ //
258
+ // This preset deliberately has no tier differentiation: its saving is the model, and the
259
+ // effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
260
+ // including `cheap` — so the hook still pins the model (a session started on opus does
261
+ // not leak into its subagents) while the tier table itself makes no distinction.
262
+ preset(
263
+ "sonnet-economy",
264
+ { model: "sonnet", effort: "xhigh" },
265
+ {
266
+ smart: { model: "sonnet", effort: "xhigh" },
267
+ normal: { model: "sonnet", effort: "xhigh" },
268
+ cheap: { model: "sonnet", effort: "xhigh" },
269
+ },
270
+ ),
271
+ ];
272
+
273
+ export const DEFAULT_PRESET = "fable-advisor";
274
+
275
+ export function findPreset(name: string): ModelsConfig | undefined {
276
+ return BUILT_IN_PRESETS.find((p) => p.preset === name);
277
+ }
278
+
279
+ export function presetNames(): string[] {
280
+ return BUILT_IN_PRESETS.map((p) => p.preset);
281
+ }
@@ -1,3 +1,5 @@
1
+ import type { ModelsConfig } from "../services/models-core.js";
2
+
1
3
  export interface PredefinedProfile {
2
4
  id: string;
3
5
  name: string;
@@ -7,6 +9,13 @@ export interface PredefinedProfile {
7
9
  anthropicPlugins: string[];
8
10
  skills: string[];
9
11
  settings: Record<string, unknown>;
12
+ /**
13
+ * Per-subagent model routing a profile inherits via `extends`. None of the
14
+ * profiles below declare one — routing is chosen per project by `claudeup
15
+ * models use`, not bundled with a plugin set — but the field is here so the
16
+ * inheritance path in `resolveExtends` is real rather than notional.
17
+ */
18
+ models?: ModelsConfig;
10
19
  }
11
20
 
12
21
  export const PREDEFINED_PROFILES: PredefinedProfile[] = [
@@ -15,7 +24,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
15
24
  name: "Must Have",
16
25
  description: "Essential plugins every Claude Code user should have",
17
26
  icon: "★",
18
- magusPlugins: ["statusline", "multimodel"],
27
+ magusPlugins: ["setup", "multimodel"],
19
28
  anthropicPlugins: [
20
29
  "claude-code-setup",
21
30
  "claude-md-management",
@@ -41,7 +50,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
41
50
  "Must Have + full dev toolkit: code analysis, browser, terminal, design, review",
42
51
  icon: "▶",
43
52
  magusPlugins: [
44
- "statusline",
53
+ "setup",
45
54
  "multimodel",
46
55
  "code-analysis",
47
56
  "dev",
@@ -84,7 +93,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
84
93
  "dev",
85
94
  "code-analysis",
86
95
  "terminal",
87
- "statusline",
96
+ "setup",
88
97
  "designer",
89
98
  "browser-use",
90
99
  "multimodel",
@@ -125,7 +134,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
125
134
  "dev",
126
135
  "code-analysis",
127
136
  "terminal",
128
- "statusline",
137
+ "setup",
129
138
  "multimodel",
130
139
  "gtd",
131
140
  ],
@@ -160,7 +169,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
160
169
  "dev",
161
170
  "code-analysis",
162
171
  "terminal",
163
- "statusline",
172
+ "setup",
164
173
  "multimodel",
165
174
  "gtd",
166
175
  "browser-use",
@@ -197,7 +206,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
197
206
  "dev",
198
207
  "code-analysis",
199
208
  "terminal",
200
- "statusline",
209
+ "setup",
201
210
  "seo@magus-marketing",
202
211
  "browser-use",
203
212
  "image-generate@magus-marketing",
@@ -232,7 +241,7 @@ export const PREDEFINED_PROFILES: PredefinedProfile[] = [
232
241
  "dev",
233
242
  "code-analysis",
234
243
  "terminal",
235
- "statusline",
244
+ "setup",
236
245
  "multimodel",
237
246
  "gtd",
238
247
  ],
@@ -66,6 +66,7 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
66
66
  { label: "Low", value: "low" },
67
67
  { label: "Medium", value: "medium" },
68
68
  { label: "High", value: "high" },
69
+ { label: "Extra high", value: "xhigh" },
69
70
  ],
70
71
  storage: { type: "setting", key: "effortLevel" },
71
72
  },
@@ -135,14 +136,20 @@ export const SETTINGS_CATALOG: SettingDefinition[] = [
135
136
  {
136
137
  id: "model",
137
138
  name: "Default Model",
138
- description: "Override the default model for Claude Code",
139
+ // Aliases, never pinned ids. A pinned id in a shipped list goes stale the
140
+ // day Anthropic moves the alias, and goes stale SILENTLY — the setting
141
+ // keeps naming a model that is a release behind, and nothing reports it.
142
+ // The alias always names whatever that tier currently is.
143
+ description:
144
+ "Override the default model for Claude Code. Aliases track the current model for each tier",
139
145
  category: "models",
140
146
  type: "select",
141
147
  options: [
142
148
  { label: "Default", value: "" },
143
- { label: "Sonnet", value: "claude-sonnet-4-6" },
144
- { label: "Opus", value: "claude-opus-4-6" },
145
- { label: "Haiku", value: "claude-haiku-4-5-20251001" },
149
+ { label: "Opus", value: "opus" },
150
+ { label: "Sonnet", value: "sonnet" },
151
+ { label: "Haiku", value: "haiku" },
152
+ { label: "Fable", value: "fable" },
146
153
  ],
147
154
  storage: { type: "setting", key: "model" },
148
155
  },
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) => {