claudeup 6.7.1 → 6.8.1
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/bin/claudeup.js +116 -12
- package/package.json +4 -4
- package/scripts/build-binaries.ts +26 -1
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/launcher-signal.test.ts +314 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +29 -2
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/cli/upgrade.ts +7 -1
- package/src/data/models-presets.ts +62 -2
- package/src/services/binary-signing.ts +78 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The agent tier table
|
|
2
|
+
* The agent tier table and the workflow list are keyed by agent id, and nothing else
|
|
3
|
+
* checks those ids.
|
|
3
4
|
*
|
|
4
5
|
* `DEFAULT_AGENT_GRADES` in `data/models-presets.ts` names
|
|
5
6
|
* agents as `plugin:name`. A rename in the marketplace (`code-search:analyze` →
|
|
@@ -17,7 +18,11 @@
|
|
|
17
18
|
import { describe, expect, test } from "bun:test";
|
|
18
19
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
19
20
|
import { join, resolve } from "node:path";
|
|
20
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
DEFAULT_AGENT_GRADES,
|
|
23
|
+
VISION_AGENTS,
|
|
24
|
+
WORKFLOWS,
|
|
25
|
+
} from "../data/models-presets.js";
|
|
21
26
|
import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
|
|
22
27
|
|
|
23
28
|
const REPO = resolve(import.meta.dir, "..", "..", "..", "..");
|
|
@@ -100,6 +105,28 @@ describe.skipIf(!inMagusSrc)(
|
|
|
100
105
|
expect(dead).toEqual([]);
|
|
101
106
|
});
|
|
102
107
|
|
|
108
|
+
/**
|
|
109
|
+
* (c) `WORKFLOWS` names agent ids too, and until now nothing checked them.
|
|
110
|
+
*
|
|
111
|
+
* These ids drive the per-workflow spread graphic. A stale one matches no agent, so
|
|
112
|
+
* its segment silently vanishes and the chart goes on drawing a confident answer
|
|
113
|
+
* about a workflow it has mis-measured — the same silent-drift failure (a) exists to
|
|
114
|
+
* catch, one array over. Measured: the 13.0.0 `code-analysis` → `code-search` rename
|
|
115
|
+
* reached this array through a rebase conflict rather than through this gate.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT folded into `presetKeys`. Being named in a workflow is not a
|
|
118
|
+
* routing classification, so counting it in (b) would let a workflow mention excuse
|
|
119
|
+
* an agent from ever being given a tier.
|
|
120
|
+
*/
|
|
121
|
+
test("(c) every workflow agent names an existing agent or a built-in", () => {
|
|
122
|
+
const workflowKeys = WORKFLOWS.flatMap((w) => [
|
|
123
|
+
...w.agents,
|
|
124
|
+
...w.external.map((step) => step.agent),
|
|
125
|
+
]);
|
|
126
|
+
const dead = workflowKeys.filter((k) => !BUILT_IN.has(k) && !ids.has(k));
|
|
127
|
+
expect(dead).toEqual([]);
|
|
128
|
+
});
|
|
129
|
+
|
|
103
130
|
test("(b) every marketplace agent is classified somewhere", () => {
|
|
104
131
|
const classified = new Set<string>([
|
|
105
132
|
...presetKeys,
|
|
@@ -24,6 +24,11 @@ function snapshot(over: Partial<ModelsSnapshot> = {}): ModelsSnapshot {
|
|
|
24
24
|
};
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
/** Drop `//` line comments and block comments, so a source guard matches code only. */
|
|
28
|
+
function stripComments(source: string): string {
|
|
29
|
+
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
/** Navigating away and back is what unmounts the screen. */
|
|
28
33
|
const roundTrip: AppAction[] = [
|
|
29
34
|
{ type: "NAVIGATE", route: { screen: "plugins" } },
|
|
@@ -36,6 +41,9 @@ describe("models screen initial state", () => {
|
|
|
36
41
|
expect(initialState.models.searchQuery).toBe("");
|
|
37
42
|
expect(initialState.models.status).toBeNull();
|
|
38
43
|
expect(initialState.models.isApplying).toBe(false);
|
|
44
|
+
// The mate slots are not drawn until something says they can be served. Starting
|
|
45
|
+
// true would put three rows on screen before anything had been asked.
|
|
46
|
+
expect(initialState.models.matesAvailable).toBe(false);
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
test("the models route is reachable and NAVIGATE keeps the screen's state", () => {
|
|
@@ -194,6 +202,44 @@ describe("apply-in-flight state", () => {
|
|
|
194
202
|
});
|
|
195
203
|
});
|
|
196
204
|
|
|
205
|
+
describe("the mate slots' availability", () => {
|
|
206
|
+
test("survives the screen unmounting, so the lookup happens once a session", () => {
|
|
207
|
+
// Unlike the two file reads this screen does on mount, this answer costs a
|
|
208
|
+
// marketplace resolution with network timeouts behind it. Held locally it would die
|
|
209
|
+
// on every tab switch and be paid for again on every return — for something that
|
|
210
|
+
// cannot change while the TUI is open.
|
|
211
|
+
const available = run([
|
|
212
|
+
{ type: "MODELS_MATES_AVAILABLE", available: true },
|
|
213
|
+
]);
|
|
214
|
+
expect(available.models.matesAvailable).toBe(true);
|
|
215
|
+
expect(run(roundTrip, available).models.matesAvailable).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("a false answer is recorded, not treated as no answer", () => {
|
|
219
|
+
// "Not installed" and "never asked" render the same, and that is deliberate — but
|
|
220
|
+
// the reducer still has to hold the difference, or a later dispatch could not undo it.
|
|
221
|
+
const state = run([
|
|
222
|
+
{ type: "MODELS_MATES_AVAILABLE", available: true },
|
|
223
|
+
{ type: "MODELS_MATES_AVAILABLE", available: false },
|
|
224
|
+
]);
|
|
225
|
+
expect(state.models.matesAvailable).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("it touches no other part of the models state", () => {
|
|
229
|
+
const before = run([
|
|
230
|
+
{ type: "MODELS_SELECT", index: 2 },
|
|
231
|
+
{ type: "MODELS_DATA_SUCCESS", snapshot: snapshot() },
|
|
232
|
+
]);
|
|
233
|
+
const after = run(
|
|
234
|
+
[{ type: "MODELS_MATES_AVAILABLE", available: true }],
|
|
235
|
+
before,
|
|
236
|
+
);
|
|
237
|
+
expect(after.models.selectedIndex).toBe(2);
|
|
238
|
+
expect(after.models.data).toBe(before.models.data);
|
|
239
|
+
expect(after.styles).toBe(before.styles);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
197
243
|
describe("selection state", () => {
|
|
198
244
|
test("select records the index verbatim", () => {
|
|
199
245
|
expect(
|
|
@@ -253,7 +299,17 @@ describe("ModelsScreen keybindings", () => {
|
|
|
253
299
|
test("screen state is held in the reducer, never in useState", async () => {
|
|
254
300
|
// The rule the two "survives unmounting" tests above enforce at runtime,
|
|
255
301
|
// pinned at the source so a new piece of state cannot quietly opt out.
|
|
256
|
-
|
|
302
|
+
//
|
|
303
|
+
// Comments are stripped first, the way hook-import-policy.test.ts does it. The rule
|
|
304
|
+
// is about CODE, and this screen documents why each piece of its state lives in the
|
|
305
|
+
// reducer — naming the hook it is deliberately not using is the clearest way to say
|
|
306
|
+
// that, and a guard reading the prose would forbid the explanation along with the
|
|
307
|
+
// thing explained.
|
|
308
|
+
const src = stripComments(await fs.readFile(SCREEN, "utf8"));
|
|
257
309
|
expect(src).not.toContain("useState");
|
|
310
|
+
// Negative control: stripping must not have eaten the code being guarded. A guard
|
|
311
|
+
// over an empty string passes forever.
|
|
312
|
+
expect(src).toContain("useEffect");
|
|
313
|
+
expect(src).toContain("useCallback");
|
|
258
314
|
});
|
|
259
315
|
});
|
package/src/cli/doctor.ts
CHANGED
|
@@ -21,7 +21,6 @@ import { readManifest } from "../services/manifest.js";
|
|
|
21
21
|
import {
|
|
22
22
|
readModelsConfig,
|
|
23
23
|
readModelsStatus,
|
|
24
|
-
reapplyModels,
|
|
25
24
|
} from "../services/models-manager.js";
|
|
26
25
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
27
26
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
@@ -172,19 +171,15 @@ async function checkModels(projectPath: string, fix: boolean): Promise<number> {
|
|
|
172
171
|
);
|
|
173
172
|
}
|
|
174
173
|
|
|
174
|
+
// No drift check. Settings differing from the config is what a CUSTOM config is, and
|
|
175
|
+
// `doctor` reports problems — a project running models no shipped preset names has none.
|
|
176
|
+
// So `doctor --fix` no longer reapplies anything: `invalid` returns early above (those
|
|
177
|
+
// are choices, not damage) and `unhooked` is repaired by registering the hook. Writing
|
|
178
|
+
// settings back from the config is `claudeup models use <preset>`, which is a decision
|
|
179
|
+
// the user makes, not a repair.
|
|
175
180
|
const status = await readModelsStatus(projectPath);
|
|
176
|
-
if (status.state === "
|
|
177
|
-
console.log("
|
|
178
|
-
for (const line of status.drift) console.log(` ${line}`);
|
|
179
|
-
if (fix) {
|
|
180
|
-
await reapplyModels(projectPath);
|
|
181
|
-
console.log(" Re-applied the config (fixed).");
|
|
182
|
-
} else {
|
|
183
|
-
console.log(" Fix: claudeup doctor --fix");
|
|
184
|
-
problems++;
|
|
185
|
-
}
|
|
186
|
-
} else if (status.state === "on") {
|
|
187
|
-
console.log(` ✓ preset "${status.preset}" applied, no drift.`);
|
|
181
|
+
if (status.state === "on") {
|
|
182
|
+
console.log(" ✓ routing is applied.");
|
|
188
183
|
}
|
|
189
184
|
|
|
190
185
|
for (const warning of status.warnings) console.log(` ⚠ ${warning}`);
|
package/src/cli/models.ts
CHANGED
|
@@ -18,10 +18,20 @@ import {
|
|
|
18
18
|
presetLabel,
|
|
19
19
|
presetNames,
|
|
20
20
|
} from "../data/models-presets.js";
|
|
21
|
+
import {
|
|
22
|
+
loadMateCatalog,
|
|
23
|
+
stalenessNote,
|
|
24
|
+
unknownBindings,
|
|
25
|
+
} from "../services/mate-catalog.js";
|
|
21
26
|
import {
|
|
22
27
|
GRADES,
|
|
23
28
|
type ModelsConfig,
|
|
24
29
|
type ModelsState,
|
|
30
|
+
boundModel,
|
|
31
|
+
isMate,
|
|
32
|
+
mateEffort,
|
|
33
|
+
matesInUse,
|
|
34
|
+
sameRouting,
|
|
25
35
|
} from "../services/models-core.js";
|
|
26
36
|
import {
|
|
27
37
|
applyModelPreset,
|
|
@@ -35,13 +45,34 @@ import { ensureManifest } from "./bootstrap.js";
|
|
|
35
45
|
const HEADLINE: Record<ModelsState, string> = {
|
|
36
46
|
off: "off — no .claude/models.json, subagents inherit the session model",
|
|
37
47
|
on: "on",
|
|
38
|
-
stale: "stale — settings no longer match the config",
|
|
39
48
|
invalid: "invalid — nothing is routed",
|
|
40
49
|
unhooked: "unhooked — the config is there, but nothing runs it",
|
|
41
50
|
};
|
|
42
51
|
|
|
43
|
-
/**
|
|
44
|
-
|
|
52
|
+
/**
|
|
53
|
+
* `opus (medium)`, or `opus` when no effort is set.
|
|
54
|
+
*
|
|
55
|
+
* A mate prints the SLOT and what it is bound to — `mate1 → grok-4.6` — because both are
|
|
56
|
+
* answers to different questions the reader has: which role this tier plays, and which model
|
|
57
|
+
* plays it. The slot alone was all that could be printed before the `mates` block existed,
|
|
58
|
+
* and a bare `kangaroo` in a column of `opus (xhigh)` read as a model id nobody recognises.
|
|
59
|
+
*
|
|
60
|
+
* An effort on a mate is qualified `(declared)`, for the reason spelled out at
|
|
61
|
+
* `DECLARED_SUFFIX`: claudeup writes it for claudish and nothing applies it yet, so printing
|
|
62
|
+
* a bare `(high)` beside a tier where `(high)` IS in force would claim they do the same
|
|
63
|
+
* thing.
|
|
64
|
+
*/
|
|
65
|
+
function describeSpec(
|
|
66
|
+
config: ModelsConfig,
|
|
67
|
+
spec: { model: string; effort?: string },
|
|
68
|
+
): string {
|
|
69
|
+
if (isMate(spec.model)) {
|
|
70
|
+
const bound = boundModel(config, spec.model);
|
|
71
|
+
const effort = spec.effort ?? mateEffort(config, spec.model);
|
|
72
|
+
const target = bound ?? "unbound";
|
|
73
|
+
const note = effort ? `${effort} declared, via claudish` : "via claudish";
|
|
74
|
+
return `${spec.model} → ${target} (${note})`;
|
|
75
|
+
}
|
|
45
76
|
return spec.effort ? `${spec.model} (${spec.effort})` : spec.model;
|
|
46
77
|
}
|
|
47
78
|
|
|
@@ -58,24 +89,82 @@ function printPreset(preset: ModelsConfig, active: boolean): void {
|
|
|
58
89
|
console.log(
|
|
59
90
|
`${mark} ${presetLabel(preset.preset)} — ${preset.preset}${isDefault}`,
|
|
60
91
|
);
|
|
61
|
-
console.log(` main ${describeSpec(preset.main)}`);
|
|
92
|
+
console.log(` main ${describeSpec(preset, preset.main)}`);
|
|
62
93
|
for (const grade of GRADES) {
|
|
63
94
|
console.log(
|
|
64
|
-
` ${grade.padEnd(10)} ${describeSpec(preset.grades[grade])}`,
|
|
95
|
+
` ${grade.padEnd(10)} ${describeSpec(preset, preset.grades[grade])}`,
|
|
65
96
|
);
|
|
66
97
|
}
|
|
67
98
|
}
|
|
68
99
|
|
|
69
100
|
async function runList(projectPath: string): Promise<number> {
|
|
70
101
|
const { config } = await readModelsConfig(projectPath);
|
|
102
|
+
|
|
103
|
+
// Has the project diverged from the built-in it NAMES?
|
|
104
|
+
//
|
|
105
|
+
// The test used to be "does it name a preset the built-ins do not", which misses the
|
|
106
|
+
// ordinary case: `claudeup models use opus-lead` writes that name, and every hand edit
|
|
107
|
+
// afterwards leaves it in place. So a config with a `mates` block was reported as the
|
|
108
|
+
// built-in `opus-lead`, and the `●` sat on a preset that is not what runs. Compared by
|
|
109
|
+
// CONTENT, shared with the TUI through `sameRouting` so the two cannot disagree about
|
|
110
|
+
// which routing is in force.
|
|
111
|
+
const builtInNamed = BUILT_IN_PRESETS.find(
|
|
112
|
+
(preset) => preset.preset === config?.preset,
|
|
113
|
+
);
|
|
114
|
+
const diverged =
|
|
115
|
+
config !== null && (!builtInNamed || !sameRouting(config, builtInNamed));
|
|
116
|
+
|
|
71
117
|
console.log("\nModel tiers:\n");
|
|
72
118
|
for (const preset of BUILT_IN_PRESETS) {
|
|
73
|
-
printPreset(preset, config?.preset === preset.preset);
|
|
119
|
+
printPreset(preset, !diverged && config?.preset === preset.preset);
|
|
74
120
|
console.log();
|
|
75
121
|
}
|
|
76
|
-
if (config &&
|
|
77
|
-
|
|
122
|
+
if (config && diverged) {
|
|
123
|
+
// The project's own config gets its tiers PRINTED, not just its name.
|
|
124
|
+
//
|
|
125
|
+
// It is the routing actually in force, and it is the only config here that can name a
|
|
126
|
+
// mate — no built-in does — so printing the name alone hid the one line a reader came
|
|
127
|
+
// for. The built-ins above are shown in full; showing the active one in less detail
|
|
128
|
+
// than the ones it replaced was backwards.
|
|
129
|
+
printPreset(config, true);
|
|
130
|
+
console.log(" (this project's own, not a built-in)\n");
|
|
78
131
|
}
|
|
132
|
+
|
|
133
|
+
// Which agents leave Claude Code entirely.
|
|
134
|
+
//
|
|
135
|
+
// `models list` prints tiers, never agents, so a mate assigned per-agent — the ordinary
|
|
136
|
+
// way to use one — appears nowhere else in this command's output. It is also the one
|
|
137
|
+
// routing decision here that claudeup does not carry out itself, which makes it exactly
|
|
138
|
+
// the thing worth naming rather than leaving to be discovered in the file.
|
|
139
|
+
const mated = Object.entries(config?.agents ?? {}).filter(
|
|
140
|
+
([, assignment]) =>
|
|
141
|
+
typeof assignment !== "string" && isMate(assignment.model),
|
|
142
|
+
);
|
|
143
|
+
if (config && mated.length > 0) {
|
|
144
|
+
console.log("Routed outside Claude Code, through claudish:\n");
|
|
145
|
+
for (const [agent, assignment] of mated) {
|
|
146
|
+
const slot =
|
|
147
|
+
typeof assignment === "string" ? assignment : assignment.model;
|
|
148
|
+
// The SLOT and the model it names. The slot is what the file says and what the
|
|
149
|
+
// reader greps for; the bound id is what actually runs. Printing only the slot was
|
|
150
|
+
// the CLI's version of the screen's `mate1 mate1 —` — a role where a model belongs.
|
|
151
|
+
const bound = isMate(slot) ? boundModel(config, slot) : null;
|
|
152
|
+
const target = bound ?? "unbound — add it to the mates block";
|
|
153
|
+
console.log(` ${agent.padEnd(28)} ${slot.padEnd(10)} ${target}`);
|
|
154
|
+
}
|
|
155
|
+
console.log();
|
|
156
|
+
|
|
157
|
+
// The staleness advisory, on the same terms as everywhere else: ADVISORY, and silent
|
|
158
|
+
// when the catalogue could not be read. `unknownBindings` returns nothing for an empty
|
|
159
|
+
// catalogue, so a machine without claudish prints no note rather than flagging every
|
|
160
|
+
// binding it cannot check.
|
|
161
|
+
const bound = matesInUse(config)
|
|
162
|
+
.map((mate) => boundModel(config, mate))
|
|
163
|
+
.filter((id): id is string => id !== null);
|
|
164
|
+
const note = stalenessNote(unknownBindings(bound, await loadMateCatalog()));
|
|
165
|
+
if (note) console.log(`⚠ ${note}\n`);
|
|
166
|
+
}
|
|
167
|
+
|
|
79
168
|
console.log(
|
|
80
169
|
"Tiers name what a subagent is FOR: `smart` takes the judgement calls, `cheap`",
|
|
81
170
|
);
|
|
@@ -97,11 +186,6 @@ async function runStatus(projectPath: string): Promise<number> {
|
|
|
97
186
|
if (status.drift.length > 0) {
|
|
98
187
|
console.log(status.state === "invalid" ? "\nErrors:" : "\nDrift:");
|
|
99
188
|
for (const line of status.drift) console.log(` ✗ ${line}`);
|
|
100
|
-
if (status.state === "stale") {
|
|
101
|
-
console.log(
|
|
102
|
-
"\n Fix: claudeup models use <preset> (or claudeup doctor --fix)",
|
|
103
|
-
);
|
|
104
|
-
}
|
|
105
189
|
if (status.state === "unhooked")
|
|
106
190
|
console.log("\n Fix: claudeup doctor --fix");
|
|
107
191
|
}
|
package/src/cli/upgrade.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { spawn } from "node:child_process";
|
|
14
|
+
import { constants } from "node:os";
|
|
14
15
|
|
|
15
16
|
export async function runUpgradeCommand(): Promise<number> {
|
|
16
17
|
const { execSync } = await import("node:child_process");
|
|
@@ -85,7 +86,12 @@ export async function runUpgradeCommand(): Promise<number> {
|
|
|
85
86
|
stdio: "inherit",
|
|
86
87
|
shell: false, // Avoid shell for security (fixes DEP0190 warning)
|
|
87
88
|
});
|
|
88
|
-
|
|
89
|
+
// `code ?? 0` is the same defect 6.8.1 removed from bin/claudeup.js: a
|
|
90
|
+
// package manager killed by a signal delivers code null, and reporting
|
|
91
|
+
// that upgrade as a success is the worst answer available.
|
|
92
|
+
proc.on("exit", (code, signal) =>
|
|
93
|
+
resolve(code ?? (signal ? 128 + (constants.signals[signal] ?? 0) : 1)),
|
|
94
|
+
);
|
|
89
95
|
proc.on("error", () => resolve(1));
|
|
90
96
|
});
|
|
91
97
|
}
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* `medium`) rather than a harder-working one. What holds everywhere is the other half: the
|
|
14
14
|
* thread dispatching the work is never strictly the biggest spend in the preset.
|
|
15
15
|
*/
|
|
16
|
-
import type { Grade, ModelsConfig } from "../services/models-core.js";
|
|
16
|
+
import type { Effort, Grade, ModelsConfig } from "../services/models-core.js";
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* Which tier each subagent gets, shared by every preset.
|
|
@@ -70,8 +70,43 @@ export const DEFAULT_AGENT_GRADES: Record<string, Grade> = {
|
|
|
70
70
|
* Read out of `plugins/dev/commands/{dev,debug,investigate}.md` — agents only. Those files
|
|
71
71
|
* also name skills (`dev:context-detection`, `code-search:investigate`) which are not
|
|
72
72
|
* dispatched through the Agent tool and so are not routed at all.
|
|
73
|
+
*
|
|
74
|
+
* ## `external` — the steps that leave Claude Code, whatever the preset says
|
|
75
|
+
*
|
|
76
|
+
* A workflow's use of an outside model is a property of the WORKFLOW, not of the routing
|
|
77
|
+
* config. `/dev:dev` runs a multi-model plan review at Phase 3 and a multi-model code review
|
|
78
|
+
* at Phase 5 because its command file says so, and switching from `Sonnet` to `Opus main`
|
|
79
|
+
* does not change that. So these steps are declared here beside the agents and are drawn
|
|
80
|
+
* under every preset, rather than being read out of `config.agents` — where they would
|
|
81
|
+
* appear only for a project that happened to have hand-bound a slot.
|
|
82
|
+
*
|
|
83
|
+
* Read out of the same command files:
|
|
84
|
+
* `plugins/dev/commands/dev.md:138` Plan review (external models via claudish) → dev:architect
|
|
85
|
+
* `plugins/dev/commands/dev.md:140` Code review (external models via claudish) → dev:architect
|
|
86
|
+
*
|
|
87
|
+
* `debug.md` and `investigate.md` contain no claudish reference at all, so both are empty
|
|
88
|
+
* here — and that absence is worth drawing, because "this flow stays inside Claude Code" is
|
|
89
|
+
* exactly as useful to know as the opposite.
|
|
90
|
+
*
|
|
91
|
+
* A STEP, not an agent: `dev:architect` appears in `agents` above as well, because the same
|
|
92
|
+
* agent does the Phase 3 planning on a Claude model. Marking the agent external would be
|
|
93
|
+
* wrong in both directions — it would move the planning outside and merge the two reviews
|
|
94
|
+
* into it.
|
|
73
95
|
*/
|
|
74
|
-
export
|
|
96
|
+
export interface WorkflowStep {
|
|
97
|
+
/** What the phase is called in the command file, for the reader who goes looking. */
|
|
98
|
+
step: string;
|
|
99
|
+
/** The agent the step dispatches. Recorded for provenance; routing is claudish's. */
|
|
100
|
+
agent: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface Workflow {
|
|
104
|
+
name: string;
|
|
105
|
+
agents: string[];
|
|
106
|
+
external: WorkflowStep[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const WORKFLOWS: Workflow[] = [
|
|
75
110
|
{
|
|
76
111
|
name: "dev",
|
|
77
112
|
agents: [
|
|
@@ -81,6 +116,10 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
81
116
|
"dev:qa-engineer",
|
|
82
117
|
"dev:reviewer",
|
|
83
118
|
],
|
|
119
|
+
external: [
|
|
120
|
+
{ step: "plan review", agent: "dev:architect" },
|
|
121
|
+
{ step: "code review", agent: "dev:architect" },
|
|
122
|
+
],
|
|
84
123
|
},
|
|
85
124
|
{
|
|
86
125
|
name: "debug",
|
|
@@ -90,10 +129,12 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
90
129
|
"dev:debugger",
|
|
91
130
|
"dev:developer",
|
|
92
131
|
],
|
|
132
|
+
external: [],
|
|
93
133
|
},
|
|
94
134
|
{
|
|
95
135
|
name: "investigate",
|
|
96
136
|
agents: ["code-search:analyze", "dev:researcher"],
|
|
137
|
+
external: [],
|
|
97
138
|
},
|
|
98
139
|
];
|
|
99
140
|
|
|
@@ -113,10 +154,25 @@ export const WORKFLOWS: { name: string; agents: string[] }[] = [
|
|
|
113
154
|
*/
|
|
114
155
|
export const VISION_AGENTS = ["designer:review", "designer:ui"] as const;
|
|
115
156
|
|
|
157
|
+
/**
|
|
158
|
+
* The effort the external slots run at, per preset.
|
|
159
|
+
*
|
|
160
|
+
* A preset cannot say WHICH model serves a mate — a catalogue id shipped in this repo goes
|
|
161
|
+
* stale, which is the whole reason bindings live in a project's own `mates` block. It can say
|
|
162
|
+
* how hard that work should try, and that half does not depend on which model answers.
|
|
163
|
+
*
|
|
164
|
+
* `xhigh` almost everywhere: a slot is reached for the judgement calls — plan review, code
|
|
165
|
+
* review — and those are the calls worth spending on whichever model takes them. `Sonnet` is
|
|
166
|
+
* the exception because it is the economy preset, and a preset whose whole point is to spend
|
|
167
|
+
* less would be lying if the work it sent outside ignored that.
|
|
168
|
+
*/
|
|
169
|
+
const MATE_EFFORT_DEFAULT: Effort = "xhigh";
|
|
170
|
+
|
|
116
171
|
function preset(
|
|
117
172
|
name: string,
|
|
118
173
|
main: ModelsConfig["main"],
|
|
119
174
|
grades: ModelsConfig["grades"],
|
|
175
|
+
mateEffort: Effort = MATE_EFFORT_DEFAULT,
|
|
120
176
|
): ModelsConfig {
|
|
121
177
|
return {
|
|
122
178
|
version: 1,
|
|
@@ -125,6 +181,7 @@ function preset(
|
|
|
125
181
|
grades,
|
|
126
182
|
agents: { ...DEFAULT_AGENT_GRADES },
|
|
127
183
|
fallback: "normal",
|
|
184
|
+
mateEffort,
|
|
128
185
|
};
|
|
129
186
|
}
|
|
130
187
|
|
|
@@ -247,6 +304,8 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
|
|
|
247
304
|
// effort is not throttled on top of it. Every routed agent runs sonnet at `xhigh`,
|
|
248
305
|
// including `cheap` — so the hook still pins the model (a session started on opus does
|
|
249
306
|
// not leak into its subagents) while the tier table itself makes no distinction.
|
|
307
|
+
// The one preset whose mates run at `medium` rather than `xhigh`: an economy preset that
|
|
308
|
+
// spent freely the moment work left Claude Code would not be one.
|
|
250
309
|
preset(
|
|
251
310
|
"sonnet-economy",
|
|
252
311
|
{ model: "sonnet", effort: "xhigh" },
|
|
@@ -255,6 +314,7 @@ export const BUILT_IN_PRESETS: ModelsConfig[] = [
|
|
|
255
314
|
normal: { model: "sonnet", effort: "xhigh" },
|
|
256
315
|
cheap: { model: "sonnet", effort: "xhigh" },
|
|
257
316
|
},
|
|
317
|
+
"medium",
|
|
258
318
|
),
|
|
259
319
|
];
|
|
260
320
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code-sign a compiled claudeup binary, and refuse to ship one that does not
|
|
3
|
+
* verify.
|
|
4
|
+
*
|
|
5
|
+
* `bun build --compile` produces a Mach-O carrying an ad-hoc, linker-signed
|
|
6
|
+
* signature (`flags=0x20002(adhoc,linker-signed)`) and then appends the
|
|
7
|
+
* JavaScript bundle to the end of the file. The signature therefore describes
|
|
8
|
+
* a shorter file than the one that ships. `codesign -v` has reported
|
|
9
|
+
* "invalid signature (code or signature have been modified)" on every
|
|
10
|
+
* published claudeup binary back to 4.22.0.
|
|
11
|
+
*
|
|
12
|
+
* macOS 27 refuses to exec such a binary: SIGKILL at startup, no output. The
|
|
13
|
+
* build never signed anything, so nothing caught it — `codesign` appeared
|
|
14
|
+
* nowhere in this package.
|
|
15
|
+
*
|
|
16
|
+
* Signing is ad-hoc (`--sign -`) rather than Developer ID on purpose: files
|
|
17
|
+
* installed by npm are not quarantined, so Gatekeeper never evaluates them.
|
|
18
|
+
* The kernel's signature check is the only gate that was failing, and an
|
|
19
|
+
* ad-hoc signature satisfies it — with no Apple account, no CI secrets and no
|
|
20
|
+
* notarization wait.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawnSync } from "node:child_process";
|
|
24
|
+
|
|
25
|
+
/** Result of one external command. Injected so the logic is testable. */
|
|
26
|
+
export type Run = (
|
|
27
|
+
cmd: string,
|
|
28
|
+
args: string[],
|
|
29
|
+
) => { status: number | null; stderr: string };
|
|
30
|
+
|
|
31
|
+
const realRun: Run = (cmd, args) => {
|
|
32
|
+
const result = spawnSync(cmd, args, { encoding: "utf8" });
|
|
33
|
+
// Fail closed and say why. A missing `codesign` must stop the build, not
|
|
34
|
+
// quietly skip signing — and the raw ENOENT names neither the binary being
|
|
35
|
+
// signed nor the reason signing is mandatory.
|
|
36
|
+
if (result.error) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`Could not run \`${cmd}\` while signing ${args[args.length - 1]}: ${result.error.message}\nSigning is mandatory for darwin binaries — macOS kills an unsigned one at exec.`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
return { status: result.status, stderr: result.stderr ?? "" };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Only darwin. `codesign` does not exist elsewhere, and no other platform
|
|
46
|
+
* validates a signature at exec.
|
|
47
|
+
*/
|
|
48
|
+
export const needsSigning = (target: { os: string }): boolean =>
|
|
49
|
+
target.os === "darwin";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sign the binary, then verify it. A failing verify THROWS.
|
|
53
|
+
*
|
|
54
|
+
* This is a gate, not an advisory. The whole defect being fixed is a release
|
|
55
|
+
* that shipped a binary whose signature did not match it; a warning here would
|
|
56
|
+
* reproduce that exactly, one log line louder.
|
|
57
|
+
*/
|
|
58
|
+
export function signAndVerify(
|
|
59
|
+
target: { os: string },
|
|
60
|
+
binPath: string,
|
|
61
|
+
run: Run = realRun,
|
|
62
|
+
): void {
|
|
63
|
+
if (!needsSigning(target)) return;
|
|
64
|
+
|
|
65
|
+
const signed = run("codesign", ["--force", "--sign", "-", binPath]);
|
|
66
|
+
if (signed.status !== 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`codesign --sign failed for ${binPath} (exit ${signed.status})\n${signed.stderr}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const verified = run("codesign", ["--verify", "--strict", binPath]);
|
|
73
|
+
if (verified.status !== 0) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`codesign --verify --strict rejected ${binPath} (exit ${verified.status}).\nRefusing to ship a binary macOS will kill at exec.\n${verified.stderr}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
}
|