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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read/modify/write a Claude Code `settings.json`, in place.
|
|
3
|
+
*
|
|
4
|
+
* Two rules, and both exist because of damage they prevent:
|
|
5
|
+
*
|
|
6
|
+
* 1. **Write IN PLACE, never remove-and-recreate.** `.claude/settings.json` is
|
|
7
|
+
* a SYMLINK into `.claude/_profiles/<name>/` whenever a profile is active,
|
|
8
|
+
* and `writeFile` follows it — which is what we want. Unlinking the file
|
|
9
|
+
* first would break the link and silently detach the project from its
|
|
10
|
+
* profile, leaving the next read looking at a fresh, empty file.
|
|
11
|
+
* 2. **Unparseable is an ERROR, not an empty object.** A settings file that
|
|
12
|
+
* fails to parse is a file we do not understand, usually because a human is
|
|
13
|
+
* mid-edit. Treating it as `{}` and writing our one key back is how a
|
|
14
|
+
* user's whole configuration disappears. We refuse and say so.
|
|
15
|
+
*
|
|
16
|
+
* A MISSING file is different from an unparseable one: nothing is lost by
|
|
17
|
+
* starting from `{}`, so that is what a missing (or empty) file reads as.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import fs from "fs-extra";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse `settingsPath`. Missing or empty reads as `{}`; unparseable throws.
|
|
25
|
+
*
|
|
26
|
+
* Exported for callers that only need the read half — the refusal wording is
|
|
27
|
+
* the same either way, so nothing has to reimplement it.
|
|
28
|
+
*/
|
|
29
|
+
export async function readSettingsFile(
|
|
30
|
+
settingsPath: string,
|
|
31
|
+
): Promise<Record<string, unknown>> {
|
|
32
|
+
if (!(await fs.pathExists(settingsPath))) return {};
|
|
33
|
+
|
|
34
|
+
const raw = (await fs.readFile(settingsPath, "utf8")).trim();
|
|
35
|
+
if (!raw) return {};
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read, apply `mutate`, write back — preserving every key we did not touch.
|
|
48
|
+
*
|
|
49
|
+
* `mutate` may edit the object in place and return nothing, or return a
|
|
50
|
+
* replacement object. Nothing is written when the read throws, so a settings
|
|
51
|
+
* file we cannot parse is left exactly as the user left it.
|
|
52
|
+
*/
|
|
53
|
+
export async function updateSettingsFile(
|
|
54
|
+
settingsPath: string,
|
|
55
|
+
// biome-ignore lint/suspicious/noConfusingVoidType: the union IS the contract — mutate in place and return nothing, or return a replacement
|
|
56
|
+
mutate: (settings: Record<string, unknown>) => Record<string, unknown> | void,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const settings = await readSettingsFile(settingsPath);
|
|
59
|
+
const next =
|
|
60
|
+
(mutate(settings) as Record<string, unknown> | undefined) ?? settings;
|
|
61
|
+
|
|
62
|
+
await fs.ensureDir(path.dirname(settingsPath));
|
|
63
|
+
// writeFile, never remove-then-create: see rule 1 above.
|
|
64
|
+
await fs.writeFile(
|
|
65
|
+
settingsPath,
|
|
66
|
+
`${JSON.stringify(next, null, 2)}\n`,
|
|
67
|
+
"utf8",
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -37,7 +37,13 @@ import path from "node:path";
|
|
|
37
37
|
import fs from "fs-extra";
|
|
38
38
|
import { findCommunityStyle } from "../data/community-styles.js";
|
|
39
39
|
import { EMBEDDED_PRESETS } from "../data/styles/index.js";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
getManifestPath,
|
|
42
|
+
mergeManifestSettings,
|
|
43
|
+
readManifest,
|
|
44
|
+
writeManifest,
|
|
45
|
+
} from "./manifest.js";
|
|
46
|
+
import { updateSettingsFile } from "./settings-file.js";
|
|
41
47
|
import { activeProfile } from "./symlink-manager.js";
|
|
42
48
|
|
|
43
49
|
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
@@ -1051,58 +1057,35 @@ export function composeStyleFile(
|
|
|
1051
1057
|
/**
|
|
1052
1058
|
* Set `outputStyle` in settings.json, preserving everything else.
|
|
1053
1059
|
*
|
|
1054
|
-
* Writes in place
|
|
1055
|
-
*
|
|
1056
|
-
* want. Removing and recreating the file would break the link and silently
|
|
1057
|
-
* detach the project from its profile.
|
|
1060
|
+
* Writes in place, and refuses a file it cannot parse — both are
|
|
1061
|
+
* `updateSettingsFile`'s contract, and both are documented there.
|
|
1058
1062
|
*/
|
|
1059
1063
|
async function setOutputStyle(
|
|
1060
1064
|
settingsPath: string,
|
|
1061
1065
|
styleName: string,
|
|
1062
1066
|
): Promise<void> {
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
if (raw) {
|
|
1067
|
-
try {
|
|
1068
|
-
settings = JSON.parse(raw) as Record<string, unknown>;
|
|
1069
|
-
} catch (error) {
|
|
1070
|
-
throw new Error(
|
|
1071
|
-
`${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
|
|
1072
|
-
);
|
|
1073
|
-
}
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
settings.outputStyle = styleName;
|
|
1077
|
-
await fs.ensureDir(path.dirname(settingsPath));
|
|
1078
|
-
await fs.writeFile(
|
|
1079
|
-
settingsPath,
|
|
1080
|
-
`${JSON.stringify(settings, null, 2)}\n`,
|
|
1081
|
-
"utf8",
|
|
1082
|
-
);
|
|
1067
|
+
await updateSettingsFile(settingsPath, (settings) => {
|
|
1068
|
+
settings.outputStyle = styleName;
|
|
1069
|
+
});
|
|
1083
1070
|
}
|
|
1084
1071
|
|
|
1085
1072
|
/**
|
|
1086
1073
|
* Record the style in the committed manifest so it survives re-materialization.
|
|
1087
1074
|
* Returns false when there is no active profile or no manifest entry for it —
|
|
1088
1075
|
* both are normal, and neither is an error.
|
|
1076
|
+
*
|
|
1077
|
+
* `mergeManifestSettings` is shared with model routing, which writes the same
|
|
1078
|
+
* `profiles.<name>.settings` for the same reason. It throws on a manifest it
|
|
1079
|
+
* cannot parse rather than reporting a success it did not achieve.
|
|
1089
1080
|
*/
|
|
1090
1081
|
async function recordInManifest(
|
|
1091
1082
|
projectPath: string,
|
|
1092
1083
|
profile: string | null,
|
|
1093
1084
|
styleName: string,
|
|
1094
1085
|
): Promise<boolean> {
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
const manifest = await readManifest(projectPath);
|
|
1099
|
-
const entry = manifest.profiles[profile];
|
|
1100
|
-
if (!entry) return false;
|
|
1101
|
-
|
|
1102
|
-
entry.settings = { ...(entry.settings ?? {}), outputStyle: styleName };
|
|
1103
|
-
entry.updatedAt = new Date().toISOString();
|
|
1104
|
-
await writeManifest(manifest, projectPath);
|
|
1105
|
-
return true;
|
|
1086
|
+
return mergeManifestSettings(projectPath, profile, {
|
|
1087
|
+
outputStyle: styleName,
|
|
1088
|
+
});
|
|
1106
1089
|
}
|
|
1107
1090
|
|
|
1108
1091
|
export interface ApplyStylesArgs {
|
|
@@ -1285,20 +1268,15 @@ export async function createTeamStyle(
|
|
|
1285
1268
|
*/
|
|
1286
1269
|
export async function clearStyle(projectPath: string): Promise<void> {
|
|
1287
1270
|
const settingsPath = settingsPathFor(projectPath);
|
|
1271
|
+
// The guard stays: a project that never had a style must not gain a
|
|
1272
|
+
// settings.json just because the style was cleared.
|
|
1288
1273
|
if (await fs.pathExists(settingsPath)) {
|
|
1289
|
-
|
|
1290
|
-
if (raw) {
|
|
1291
|
-
const settings = JSON.parse(raw) as Record<string, unknown>;
|
|
1274
|
+
await updateSettingsFile(settingsPath, (settings) => {
|
|
1292
1275
|
// The key must be ABSENT, not present-and-undefined: Claude Code reads
|
|
1293
1276
|
// presence, and an `= undefined` assignment still answers `in` checks.
|
|
1294
1277
|
// biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
|
|
1295
1278
|
delete settings.outputStyle;
|
|
1296
|
-
|
|
1297
|
-
settingsPath,
|
|
1298
|
-
`${JSON.stringify(settings, null, 2)}\n`,
|
|
1299
|
-
"utf8",
|
|
1300
|
-
);
|
|
1301
|
-
}
|
|
1279
|
+
});
|
|
1302
1280
|
}
|
|
1303
1281
|
|
|
1304
1282
|
const profile = await activeProfile(projectPath);
|
|
@@ -8,10 +8,17 @@
|
|
|
8
8
|
* .claude/settings.json -> .claude/_profiles/<name>/settings.json
|
|
9
9
|
* .claude/skills -> .claude/_profiles/<name>/skills
|
|
10
10
|
* .mcp.json -> .claude/_profiles/<name>/mcp.json
|
|
11
|
+
* .claude/models.json -> .claude/_profiles/<name>/models.json
|
|
11
12
|
*
|
|
12
13
|
* Switching profiles just repoints these links — no network, no reinstall.
|
|
13
14
|
* settings.local.json is NEVER linked: it holds personal credentials and must
|
|
14
15
|
* survive switches untouched.
|
|
16
|
+
*
|
|
17
|
+
* models.json is the one link whose target is OPTIONAL: a profile that declares
|
|
18
|
+
* no routing has none, and the rule below (remove a link into _profiles/ whose
|
|
19
|
+
* target is gone) is what keeps a dangling `.claude/models.json` out of the
|
|
20
|
+
* repo. A dangling one would not be harmless — `.claude/` is committed, so it
|
|
21
|
+
* would ship to every teammate.
|
|
15
22
|
*/
|
|
16
23
|
|
|
17
24
|
import path from "node:path";
|
|
@@ -27,6 +34,7 @@ export const PROFILE_GITIGNORE_ENTRIES = [
|
|
|
27
34
|
".claude/_profiles/",
|
|
28
35
|
".claude/settings.json",
|
|
29
36
|
".claude/skills",
|
|
37
|
+
".claude/models.json",
|
|
30
38
|
".mcp.json",
|
|
31
39
|
];
|
|
32
40
|
|
|
@@ -41,26 +49,37 @@ export function profileDir(name: string, projectPath?: string): string {
|
|
|
41
49
|
return path.join(profilesRoot(projectPath), name);
|
|
42
50
|
}
|
|
43
51
|
|
|
52
|
+
/** The artifacts an active profile links into place. */
|
|
53
|
+
export type ProfileLinkKey = "settings" | "skills" | "mcp" | "models";
|
|
54
|
+
|
|
44
55
|
/** The set of links an active profile owns, as [linkPath, targetPath] pairs. */
|
|
45
|
-
function linkTargets(
|
|
56
|
+
export function linkTargets(
|
|
46
57
|
name: string,
|
|
47
58
|
projectPath?: string,
|
|
48
|
-
): Array<{ link: string; target: string }> {
|
|
59
|
+
): Array<{ key: ProfileLinkKey; link: string; target: string }> {
|
|
49
60
|
const base = projectPath ?? process.cwd();
|
|
50
61
|
const dir = profileDir(name, projectPath);
|
|
51
62
|
return [
|
|
52
63
|
{
|
|
64
|
+
key: "settings",
|
|
53
65
|
link: path.join(base, ".claude", "settings.json"),
|
|
54
66
|
target: path.join(dir, "settings.json"),
|
|
55
67
|
},
|
|
56
68
|
{
|
|
69
|
+
key: "skills",
|
|
57
70
|
link: path.join(base, ".claude", "skills"),
|
|
58
71
|
target: path.join(dir, "skills"),
|
|
59
72
|
},
|
|
60
73
|
{
|
|
74
|
+
key: "mcp",
|
|
61
75
|
link: path.join(base, ".mcp.json"),
|
|
62
76
|
target: path.join(dir, "mcp.json"),
|
|
63
77
|
},
|
|
78
|
+
{
|
|
79
|
+
key: "models",
|
|
80
|
+
link: path.join(base, ".claude", "models.json"),
|
|
81
|
+
target: path.join(dir, "models.json"),
|
|
82
|
+
},
|
|
64
83
|
];
|
|
65
84
|
}
|
|
66
85
|
|
|
@@ -93,16 +112,43 @@ export async function activateProfile(
|
|
|
93
112
|
);
|
|
94
113
|
}
|
|
95
114
|
for (const { link, target } of linkTargets(name, projectPath)) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
await syncLink(link, target, projectPath);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Point one link at its target, or remove it when the profile has no such
|
|
121
|
+
* artifact. A link is only removed when it points into `_profiles/` — a real
|
|
122
|
+
* file the user put there is never clobbered.
|
|
123
|
+
*/
|
|
124
|
+
async function syncLink(
|
|
125
|
+
link: string,
|
|
126
|
+
target: string,
|
|
127
|
+
projectPath?: string,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (await fs.pathExists(target)) {
|
|
130
|
+
await relink(link, target);
|
|
131
|
+
return;
|
|
105
132
|
}
|
|
133
|
+
if (await isProfileLink(link, projectPath)) await fs.remove(link);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Sync ONE of the active profile's links, leaving the others alone.
|
|
138
|
+
*
|
|
139
|
+
* `activateProfile` repoints all four, which is right when switching profiles
|
|
140
|
+
* and wrong when only one artifact changed: `claudeup models use` would replace
|
|
141
|
+
* a project's real `.mcp.json` with a symlink as a side effect of choosing a
|
|
142
|
+
* model. This is the narrow door for that case.
|
|
143
|
+
*/
|
|
144
|
+
export async function syncProfileLink(
|
|
145
|
+
name: string,
|
|
146
|
+
key: ProfileLinkKey,
|
|
147
|
+
projectPath?: string,
|
|
148
|
+
): Promise<void> {
|
|
149
|
+
const entry = linkTargets(name, projectPath).find((t) => t.key === key);
|
|
150
|
+
if (!entry) return;
|
|
151
|
+
await syncLink(entry.link, entry.target, projectPath);
|
|
106
152
|
}
|
|
107
153
|
|
|
108
154
|
/** Is `link` a symlink pointing into `.claude/_profiles/`? */
|
package/src/tui.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interactive TUI: renderer creation, theme resolution, and the React root.
|
|
3
|
+
*
|
|
4
|
+
* Split out of `main.tsx` so that nothing on a non-interactive path — above all
|
|
5
|
+
* `claudeup hook …`, which Claude Code runs on every matching tool call — has
|
|
6
|
+
* to load `@opentui/*` or `src/ui/` to reach its own code. `main.tsx` imports
|
|
7
|
+
* this module dynamically, at the last possible moment.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { RGBA, type ThemeMode, createCliRenderer } from "@opentui/core";
|
|
11
|
+
import { createRoot } from "@opentui/react";
|
|
12
|
+
import { App } from "./ui/App.js";
|
|
13
|
+
import { setThemeMode } from "./ui/theme-mode.js";
|
|
14
|
+
import { type ThemeEnv, resolveTheme } from "./ui/theme-resolve.js";
|
|
15
|
+
|
|
16
|
+
export interface StartTuiOptions {
|
|
17
|
+
/** From `--theme`, already validated by the router. */
|
|
18
|
+
theme?: ThemeMode;
|
|
19
|
+
/** The theme variables, snapshotted in main.tsx BEFORE any .env merge. */
|
|
20
|
+
env: ThemeEnv;
|
|
21
|
+
/** stdin AND stdout are TTYs. Decided by the caller. */
|
|
22
|
+
isInteractive: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Start the TUI. Never resolves — the app runs until the user exits.
|
|
27
|
+
*/
|
|
28
|
+
export async function startTui({
|
|
29
|
+
theme,
|
|
30
|
+
env,
|
|
31
|
+
isInteractive,
|
|
32
|
+
}: StartTuiOptions): Promise<void> {
|
|
33
|
+
// Create OpenTUI renderer (handles alternate screen buffer automatically).
|
|
34
|
+
//
|
|
35
|
+
// The background is the terminal's own, not a colour of ours: painting an
|
|
36
|
+
// absolute fill would box the UI into whatever theme we guessed. OpenTUI's
|
|
37
|
+
// built-in default for unstyled cells is truecolor white, which is why every
|
|
38
|
+
// element now names an adaptive colour explicitly — see src/ui/theme.ts.
|
|
39
|
+
// The mouse is ON, everywhere — it carries real features now: the wheel
|
|
40
|
+
// scrolls the pane under the cursor, and a drag selects text from ONE pane
|
|
41
|
+
// at a time (terminal-native selection is a screen-wide rectangle that
|
|
42
|
+
// grabs both columns), OSC 52-copied on release below.
|
|
43
|
+
//
|
|
44
|
+
// This deliberately includes tmux. Two earlier revisions got this wrong in
|
|
45
|
+
// opposite directions: one disabled the mouse everywhere on the theory that
|
|
46
|
+
// capture breaks tmux-level pane clicking, the next kept it off only inside
|
|
47
|
+
// tmux. Both dated from when claudeup had no mouse features, so disabling
|
|
48
|
+
// cost nothing. MEASURED on a heavily tmux'd machine: tmux forwards mouse
|
|
49
|
+
// to a pane application that asks for it (htop in the same session took
|
|
50
|
+
// clicks and wheel fine while claudeup sat inert), and tmux's own `mouse`
|
|
51
|
+
// option keeps governing pane management. Terminal-native selection stays
|
|
52
|
+
// reachable via Shift+drag in most terminals.
|
|
53
|
+
//
|
|
54
|
+
// enableMouseMovement (mode-1003 "report all motion") stays off — selection
|
|
55
|
+
// needs only button-held drag events, which button reporting delivers.
|
|
56
|
+
const renderer = await createCliRenderer({
|
|
57
|
+
backgroundColor: RGBA.defaultBackground(),
|
|
58
|
+
useMouse: true,
|
|
59
|
+
enableMouseMovement: false,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Mouse selection → system clipboard, on release. OSC 52 survives SSH;
|
|
63
|
+
// terminals that block it simply ignore the sequence. Never fires where the
|
|
64
|
+
// mouse is off, so the tmux path pays nothing.
|
|
65
|
+
renderer.on("selection", (selection: { getSelectedText(): string }) => {
|
|
66
|
+
const text = selection?.getSelectedText() ?? "";
|
|
67
|
+
if (text.length > 0) renderer.copyToClipboardOSC52(text);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Resolve light vs dark once: --theme, CLAUDEUP_THEME, TERM_THEME, the OSC
|
|
71
|
+
// probe, COLORFGBG, dark — see src/ui/theme-resolve.ts. Only the disabled-row
|
|
72
|
+
// tint and the chips need this (src/ui/theme-mode.ts explains why that one
|
|
73
|
+
// case cannot be solved the way the rest of the palette is).
|
|
74
|
+
//
|
|
75
|
+
// The renderer sends OSC 10/11 at creation regardless: @opentui/core 0.1.107
|
|
76
|
+
// has no option to suppress it. So the rule "TERM_THEME skips the probe" is
|
|
77
|
+
// satisfied by NOT AWAITING — when steps 1-3 answer, `probe` is never called,
|
|
78
|
+
// `waitForThemeMode(250)` never runs, first paint is not delayed, and
|
|
79
|
+
// `renderer.themeMode` is never read anywhere in claudeup. Any late reply the
|
|
80
|
+
// terminal sends is consumed by OpenTUI's own handleSequence and ignored here.
|
|
81
|
+
const { mode } = await resolveTheme({
|
|
82
|
+
flag: theme,
|
|
83
|
+
env,
|
|
84
|
+
isInteractive,
|
|
85
|
+
probe: () => renderer.waitForThemeMode(250).catch(() => null),
|
|
86
|
+
});
|
|
87
|
+
setThemeMode(mode);
|
|
88
|
+
|
|
89
|
+
const root = createRoot(renderer);
|
|
90
|
+
|
|
91
|
+
// Cleanup function to restore terminal
|
|
92
|
+
const cleanup = () => {
|
|
93
|
+
root.unmount();
|
|
94
|
+
renderer.destroy(); // CRITICAL: Never use process.exit() directly
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Handle cleanup on exit signals
|
|
98
|
+
const handleExit = () => {
|
|
99
|
+
cleanup();
|
|
100
|
+
// Exit after cleanup completes
|
|
101
|
+
process.exit(0);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
process.on("SIGINT", handleExit);
|
|
105
|
+
process.on("SIGTERM", handleExit);
|
|
106
|
+
|
|
107
|
+
// Render the OpenTUI app with exit handler
|
|
108
|
+
root.render(<App onExit={handleExit} />);
|
|
109
|
+
|
|
110
|
+
// Wait indefinitely (app runs until user exits)
|
|
111
|
+
await new Promise(() => {});
|
|
112
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sliver of Bun's global this project actually uses.
|
|
3
|
+
*
|
|
4
|
+
* `@types/bun` is deliberately NOT a dependency — `utils/command-utils.ts` says
|
|
5
|
+
* so where it reaches for Node's `fs` instead of `Bun.which` — and the whole
|
|
6
|
+
* package would be pulled in to type two properties. `cli/hook.ts` reads its
|
|
7
|
+
* payload from `Bun.stdin.stream()` because that is the fastest path on the
|
|
8
|
+
* hottest code claudeup has (a PreToolUse hook runs on every Agent call), so
|
|
9
|
+
* the global is declared here rather than the call being rewritten.
|
|
10
|
+
*
|
|
11
|
+
* Keep this MINIMAL. Every member added here is a member nobody checks against
|
|
12
|
+
* the real runtime; a full mirror of Bun's API would be a second, worse copy of
|
|
13
|
+
* `@types/bun` that drifts silently. If this grows past a few members, take the
|
|
14
|
+
* dependency instead.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
declare namespace Bun {
|
|
18
|
+
const stdin: {
|
|
19
|
+
stream(): AsyncIterable<Uint8Array>;
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { ModelsConfig } from "../services/models-core.js";
|
|
2
|
+
|
|
1
3
|
export interface McpServer {
|
|
2
4
|
name: string;
|
|
3
5
|
description: string;
|
|
@@ -366,6 +368,12 @@ export interface ProfileManifestEntry {
|
|
|
366
368
|
cliTools?: Record<string, string>;
|
|
367
369
|
skills?: ProfileSkillRef[];
|
|
368
370
|
settings?: Record<string, unknown>;
|
|
371
|
+
/**
|
|
372
|
+
* Per-subagent model routing. A PROFILE property, so switching profiles
|
|
373
|
+
* switches routing, and materialization writes it to
|
|
374
|
+
* `_profiles/<name>/models.json` for `.claude/models.json` to link at.
|
|
375
|
+
*/
|
|
376
|
+
models?: ModelsConfig;
|
|
369
377
|
env?: ProfileEnvRequirements;
|
|
370
378
|
/** Optional bookkeeping carried over from v1 saved profiles. */
|
|
371
379
|
createdAt?: string;
|
|
@@ -427,6 +435,12 @@ export interface ResolvedClosure {
|
|
|
427
435
|
bins: ResolvedBin[];
|
|
428
436
|
skills: ProfileSkillRef[];
|
|
429
437
|
settings: Record<string, unknown>;
|
|
438
|
+
/**
|
|
439
|
+
* The profile's model routing, if it declares any. Absent means the
|
|
440
|
+
* materialized profile carries no models.json — and materialization DELETES
|
|
441
|
+
* a stale one, which is what makes `claudeup models off` take effect.
|
|
442
|
+
*/
|
|
443
|
+
models?: ModelsConfig;
|
|
430
444
|
env: { required: string[]; optional: string[] };
|
|
431
445
|
/** Cross-profile version conflicts detected while unioning profiles. */
|
|
432
446
|
conflicts?: string[];
|
package/src/ui/App.tsx
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
GitignoreScreen,
|
|
34
34
|
McpRegistryScreen,
|
|
35
35
|
McpScreen,
|
|
36
|
+
ModelsScreen,
|
|
36
37
|
PluginsScreen,
|
|
37
38
|
ProfilesScreen,
|
|
38
39
|
SettingsScreen,
|
|
@@ -83,6 +84,8 @@ function Router() {
|
|
|
83
84
|
return <GitignoreScreen />;
|
|
84
85
|
case "alias":
|
|
85
86
|
return <AliasScreen />;
|
|
87
|
+
case "models":
|
|
88
|
+
return <ModelsScreen />;
|
|
86
89
|
default:
|
|
87
90
|
return <PluginsScreen />;
|
|
88
91
|
}
|
|
@@ -133,7 +136,8 @@ function GlobalKeyHandler({
|
|
|
133
136
|
// Don't handle keys when modal is open or searching
|
|
134
137
|
if (state.modal || state.isSearching) return;
|
|
135
138
|
|
|
136
|
-
// Global navigation shortcuts (1-9) - include mcp-registry as it's
|
|
139
|
+
// Global navigation shortcuts (1-9, then 0) - include mcp-registry as it's
|
|
140
|
+
// a sub-screen of mcp
|
|
137
141
|
const isTopLevel = [
|
|
138
142
|
"plugins",
|
|
139
143
|
"mcp",
|
|
@@ -145,6 +149,7 @@ function GlobalKeyHandler({
|
|
|
145
149
|
"styles",
|
|
146
150
|
"gitignore",
|
|
147
151
|
"alias",
|
|
152
|
+
"models",
|
|
148
153
|
].includes(state.currentRoute.screen);
|
|
149
154
|
|
|
150
155
|
if (isTopLevel) {
|
|
@@ -157,6 +162,7 @@ function GlobalKeyHandler({
|
|
|
157
162
|
else if (input === "7") navigateToScreen("gitignore");
|
|
158
163
|
else if (input === "8") navigateToScreen("alias");
|
|
159
164
|
else if (input === "9") navigateToScreen("styles");
|
|
165
|
+
else if (input === "0") navigateToScreen("models");
|
|
160
166
|
|
|
161
167
|
// Tab navigation cycling
|
|
162
168
|
if (key.tab) {
|
|
@@ -170,6 +176,7 @@ function GlobalKeyHandler({
|
|
|
170
176
|
"gitignore",
|
|
171
177
|
"alias",
|
|
172
178
|
"styles",
|
|
179
|
+
"models",
|
|
173
180
|
];
|
|
174
181
|
const currentIndex = screens.indexOf(
|
|
175
182
|
state.currentRoute.screen as Screen,
|
|
@@ -217,7 +224,7 @@ function GlobalKeyHandler({
|
|
|
217
224
|
? This help
|
|
218
225
|
|
|
219
226
|
Quick Navigation
|
|
220
|
-
1 Plugins 4 Settings 7 Git State
|
|
227
|
+
1 Plugins 4 Settings 7 Git State 0 Models
|
|
221
228
|
2 Skills 5 Profiles 8 Alias
|
|
222
229
|
3 MCP Servers 6 CLI Tools 9 Styles
|
|
223
230
|
|
|
@@ -233,7 +240,12 @@ Styles
|
|
|
233
240
|
Space Tick / untick a style
|
|
234
241
|
a Apply the selection to this project
|
|
235
242
|
x Reset the selection to what is live
|
|
236
|
-
c Clear the active output style
|
|
243
|
+
c Clear the active output style
|
|
244
|
+
|
|
245
|
+
Models
|
|
246
|
+
a / Enter Route this project through the selected preset
|
|
247
|
+
c Turn model tiers off
|
|
248
|
+
r Re-read the config`,
|
|
237
249
|
"info",
|
|
238
250
|
);
|
|
239
251
|
}
|