@polderlabs/bizar 10.19.3 → 10.19.6
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/cli/bin.mjs +2 -1
- package/cli/commands/hook.mjs +2 -0
- package/cli/commands/install.mjs +120 -37
- package/cli/commands/models.mjs +22 -15
- package/cli/commands/util.mjs +8 -11
- package/cli/provision.mjs +6 -5
- package/config/claude/hooks/sessionstart-model-sync.mjs +202 -0
- package/package.json +1 -1
- package/packages/sdk/dist/version.d.ts +1 -1
- package/packages/sdk/dist/version.js +1 -1
- package/packages/sdk/package.json +1 -1
package/cli/bin.mjs
CHANGED
|
@@ -129,7 +129,8 @@ function showHelp() {
|
|
|
129
129
|
bizar install
|
|
130
130
|
bizar audit
|
|
131
131
|
bizar doctor
|
|
132
|
-
bizar update --
|
|
132
|
+
bizar update --dry-run # Preview what would change
|
|
133
|
+
bizar update --force --yes # Non-interactive full re-emit
|
|
133
134
|
|
|
134
135
|
Run \`bizar <command> --help\` for per-command help.
|
|
135
136
|
|
package/cli/commands/hook.mjs
CHANGED
|
@@ -40,6 +40,7 @@ export const HOOK_PROGRAMS = Object.freeze({
|
|
|
40
40
|
'pretooluse-bash': 'pretooluse-bash.mjs',
|
|
41
41
|
'pretooluse-editwrite': 'pretooluse-editwrite.mjs',
|
|
42
42
|
'sessionend-recall': 'sessionend-recall.mjs',
|
|
43
|
+
'sessionstart-model-sync': 'sessionstart-model-sync.mjs',
|
|
43
44
|
'sessionstart-prime': 'sessionstart-prime.mjs',
|
|
44
45
|
'simplify-guard': 'simplify-guard.mjs',
|
|
45
46
|
telemetry: 'telemetry.mjs',
|
|
@@ -61,6 +62,7 @@ export const EVENT_CHAINS = Object.freeze({
|
|
|
61
62
|
]),
|
|
62
63
|
'session-start': Object.freeze([
|
|
63
64
|
'control-inbox',
|
|
65
|
+
'sessionstart-model-sync',
|
|
64
66
|
'sessionstart-prime',
|
|
65
67
|
'persistent-mode',
|
|
66
68
|
'telemetry',
|
package/cli/commands/install.mjs
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
*
|
|
4
4
|
* install + update command families.
|
|
5
5
|
* v4.4.11+ — 'bizar install' and 'bizar update' share the same code path.
|
|
6
|
+
*
|
|
7
|
+
* v10.19.6 — `bizar update` now honors `--dry-run` / `--force` / `--yes`
|
|
8
|
+
* the same way `bizar install` does. The previous implementation called
|
|
9
|
+
* a legacy `runUpdate(args)` that swallowed every flag; this version
|
|
10
|
+
* routes update through `parseFlags` + `runInstaller` + `runRepair` so
|
|
11
|
+
* the documented flags actually do what they claim.
|
|
6
12
|
*/
|
|
7
13
|
import chalk from 'chalk';
|
|
8
14
|
import { runInstaller } from '../install.mjs';
|
|
9
|
-
import { runUpdate } from '../update.mjs';
|
|
10
15
|
import { runRepair } from '../repair.mjs';
|
|
11
16
|
import { parseFlags } from '../provision.mjs';
|
|
12
17
|
|
|
@@ -61,35 +66,53 @@ export function showInstallHelp() {
|
|
|
61
66
|
|
|
62
67
|
export function showUpdateHelp() {
|
|
63
68
|
console.log(`
|
|
64
|
-
bizar update — Update @
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
bizar update — Update @polderlabs/bizar (CLI + SDK + agents +
|
|
70
|
+
skills + hooks + commands). Refreshing Claude Code itself is also
|
|
71
|
+
handled when the bundled install.sh runs under --force.
|
|
72
|
+
|
|
67
73
|
Usage:
|
|
68
|
-
bizar update
|
|
69
|
-
bizar update --check Only print current vs. latest; do not update
|
|
70
|
-
bizar update --channel=stable|beta Pick the npm dist-tag (default: stable)
|
|
74
|
+
bizar update Refresh every Bizar-managed surface
|
|
71
75
|
bizar update --dry-run Print what would happen, change nothing
|
|
72
|
-
bizar update --force
|
|
73
|
-
|
|
76
|
+
bizar update --force | --deep Full clean re-emit: wipe Bizar-managed
|
|
77
|
+
dirs, back up settings env vars,
|
|
78
|
+
re-sync everything from the repo
|
|
79
|
+
bizar update --yes | -y Assume yes for any non-destructive prompt
|
|
80
|
+
bizar update --non-interactive Alias for --yes
|
|
74
81
|
bizar update --help Show this help
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
82
|
+
|
|
83
|
+
Behavior (v10.19.6+):
|
|
84
|
+
Single unified provisioner — the same code path as 'bizar install'
|
|
85
|
+
with mode=update. Every step is idempotent; re-running is safe.
|
|
86
|
+
|
|
87
|
+
1. Re-emits skills, commands, rules, hooks, agents, and workflows
|
|
88
|
+
from the repo source into ~/.claude/ (or $CLAUDE_CONFIG_DIR),
|
|
89
|
+
overwriting only the Bizar-managed surface and pruning stale
|
|
90
|
+
entries (F-141).
|
|
91
|
+
2. Writes the install marker so subsequent runs short-circuit when
|
|
92
|
+
nothing has changed.
|
|
93
|
+
3. With --force, re-runs the F-183 clean-install flow: wipes
|
|
94
|
+
~/.claude/{agents,skills,commands,hooks,rules,workflows,plugins}/
|
|
95
|
+
and ~/.agents/, stashes the prior settings.json env block into
|
|
96
|
+
BIZAR_SAVED_ENV, then re-emits settings.json with the operator's
|
|
97
|
+
ANTHROPIC_* and BIZAR_* keys union-merged back in (so gateway
|
|
98
|
+
URL, auth token, and BIZAR_HOME are preserved across the wipe).
|
|
99
|
+
4. Runs 'bizar doctor' after a successful update so config
|
|
100
|
+
regressions surface before the next Claude Code session.
|
|
101
|
+
5. Repairs stale bin symlinks so the operator picks up the new code
|
|
102
|
+
on the next shell prompt.
|
|
103
|
+
|
|
104
|
+
Idempotency note:
|
|
105
|
+
When the SDK on disk matches the published npm version, every step
|
|
106
|
+
above is a no-op — settings.json is rewritten only if its hash
|
|
107
|
+
drifted, sync targets are skipped when the file set is unchanged,
|
|
108
|
+
and the bin symlink repair short-circuits. A clean re-run is fast.
|
|
109
|
+
|
|
88
110
|
Examples:
|
|
89
|
-
bizar update
|
|
90
|
-
bizar update --
|
|
91
|
-
bizar update --
|
|
92
|
-
bizar update --
|
|
111
|
+
bizar update Refresh the managed surface
|
|
112
|
+
bizar update --dry-run Preview every step
|
|
113
|
+
bizar update --force Full clean re-emit, preserved env
|
|
114
|
+
bizar update --force --yes Same, no prompts (script-friendly)
|
|
115
|
+
|
|
93
116
|
Errors:
|
|
94
117
|
Network failures (registry offline / DNS) and npm permission issues
|
|
95
118
|
are surfaced with the raw npm output. The provisioner never silently
|
|
@@ -97,7 +120,34 @@ export function showUpdateHelp() {
|
|
|
97
120
|
`);
|
|
98
121
|
}
|
|
99
122
|
|
|
123
|
+
// ── Shared post-install teardown ────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Run `runRepair({})` after install/update and surface any repointed
|
|
127
|
+
* bin symlinks to the operator. Both `install()` and `update()` share
|
|
128
|
+
* this block; the previous `bizar update` skipped it entirely (audit
|
|
129
|
+
* A6), so a freshly-upgraded Bizar left stale bin symlinks pointing at
|
|
130
|
+
* the prior install until the operator manually re-sourced their shell.
|
|
131
|
+
*
|
|
132
|
+
* @param {object} [opts]
|
|
133
|
+
* @param {(opts: object) => Promise<{ ok: boolean, fixed: string[], notes?: string[] }>} [opts.runRepair]
|
|
134
|
+
* Dependency-injected for tests; defaults to the real `runRepair`.
|
|
135
|
+
*/
|
|
136
|
+
async function runPostInstallerRepair({ runRepair: runRepairDep = runRepair } = {}) {
|
|
137
|
+
try {
|
|
138
|
+
const r = await runRepairDep({});
|
|
139
|
+
if (r.fixed.length > 0) {
|
|
140
|
+
console.log(chalk.cyan('\n Repair: repointed stale bin symlinks:'));
|
|
141
|
+
for (const f of r.fixed) console.log(` ${f}`);
|
|
142
|
+
console.log(chalk.dim(' Re-run your shell or `hash -r` to pick up the new path.'));
|
|
143
|
+
}
|
|
144
|
+
} catch (err) {
|
|
145
|
+
console.log(chalk.dim(` Repair skipped: ${err.message}`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
100
149
|
// ── Command runners ────────────────────────────────────────────────────────────
|
|
150
|
+
|
|
101
151
|
export async function install(args, isHelpRequest) {
|
|
102
152
|
if (isHelpRequest) {
|
|
103
153
|
showInstallHelp();
|
|
@@ -122,16 +172,10 @@ export async function install(args, isHelpRequest) {
|
|
|
122
172
|
}
|
|
123
173
|
// v4.4.3 — After install, repair any stale bin symlinks so the
|
|
124
174
|
// user picks up the new code.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
for (const f of r.fixed) console.log(` ${f}`);
|
|
130
|
-
console.log(chalk.dim(' Re-run your shell or `hash -r` to pick up the new path.'));
|
|
131
|
-
}
|
|
132
|
-
} catch (err) {
|
|
133
|
-
console.log(chalk.dim(` Repair skipped: ${err.message}`));
|
|
134
|
-
}
|
|
175
|
+
await runPostInstallerRepair();
|
|
176
|
+
// v10.19.6 — propagate install failure to the parent shell so
|
|
177
|
+
// `bizar install && bizar doctor` short-circuits on install errors.
|
|
178
|
+
if (!result?.ok) process.exit(1);
|
|
135
179
|
}
|
|
136
180
|
|
|
137
181
|
export async function update(args, isHelpRequest) {
|
|
@@ -139,7 +183,46 @@ export async function update(args, isHelpRequest) {
|
|
|
139
183
|
showUpdateHelp();
|
|
140
184
|
return;
|
|
141
185
|
}
|
|
142
|
-
|
|
186
|
+
// v10.19.6 — route update through the same flag-parsing + installer
|
|
187
|
+
// pipeline as install so `--dry-run`, `--force`, `--yes`, etc. do
|
|
188
|
+
// what they claim. See `runUpdateWithFlags` for the testable
|
|
189
|
+
// dependency-injected core.
|
|
190
|
+
await runUpdateWithFlags({ args });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Testable core of `update()`. Default arguments bind to the real
|
|
195
|
+
* `runInstaller` / `parseFlags` / `runRepair` from this module; tests
|
|
196
|
+
* inject stubs to assert the wiring without touching disk.
|
|
197
|
+
*
|
|
198
|
+
* Sequence (mirrors install() so the audit's documented "Runs doctor +
|
|
199
|
+
* runs repair" promises become true):
|
|
200
|
+
* 1. `parseFlags(args)` → `{ mode, dryRun, force, yes }`
|
|
201
|
+
* 2. `runInstaller({ mode, dryRun, force, yes })` — runs the
|
|
202
|
+
* provisioner, then post-install `runDoctor({ silent: true })`.
|
|
203
|
+
* 3. `runRepair({})` — repoint stale bin symlinks.
|
|
204
|
+
* 4. If `runInstaller` returned `{ ok: false }`, exit(1) so scripts
|
|
205
|
+
* that gate on the exit code (`bizar update && bizar doctor`) see
|
|
206
|
+
* the failure.
|
|
207
|
+
*
|
|
208
|
+
* @param {object} [opts]
|
|
209
|
+
* @param {string[]} [opts.args]
|
|
210
|
+
* @param {(opts: { mode: string, dryRun: boolean, force: boolean, yes: boolean }) => Promise<{ ok?: boolean }>} [opts.runInstaller]
|
|
211
|
+
* @param {(argv: string[]) => { mode: string, dryRun: boolean, force: boolean, yes: boolean }} [opts.parseFlags]
|
|
212
|
+
* @param {(opts: { dryRun?: boolean }) => Promise<{ ok: boolean, fixed: string[] }>} [opts.runRepair]
|
|
213
|
+
* @returns {Promise<{ ok?: boolean }>}
|
|
214
|
+
*/
|
|
215
|
+
export async function runUpdateWithFlags({
|
|
216
|
+
args = [],
|
|
217
|
+
runInstaller: runInstallerDep = runInstaller,
|
|
218
|
+
parseFlags: parseFlagsDep = parseFlags,
|
|
219
|
+
runRepair: runRepairDep = runRepair,
|
|
220
|
+
} = {}) {
|
|
221
|
+
const { mode, dryRun, force, yes } = parseFlagsDep(args);
|
|
222
|
+
const result = await runInstallerDep({ mode, dryRun, force, yes });
|
|
223
|
+
await runPostInstallerRepair({ runRepair: runRepairDep });
|
|
224
|
+
if (!result?.ok) process.exit(1);
|
|
225
|
+
return result;
|
|
143
226
|
}
|
|
144
227
|
|
|
145
228
|
// ── run() entry point (used by bin.mjs dispatcher) ──────────────────────────────
|
package/cli/commands/models.mjs
CHANGED
|
@@ -634,23 +634,23 @@ export function deriveModelLabel(modelId, profile) {
|
|
|
634
634
|
}
|
|
635
635
|
|
|
636
636
|
/**
|
|
637
|
-
* Sync `userSelected.models` into Claude Code's `modelPicker`
|
|
637
|
+
* Sync `userSelected.models` into Claude Code's `modelPicker` setting
|
|
638
638
|
* (settings.json). The picker is what populates `/model` — `modelOverrides`
|
|
639
639
|
* alone only silences diagnostics, it does NOT add entries to the picker.
|
|
640
640
|
*
|
|
641
|
-
* Per Claude Code's settings reference: `modelPicker` is an
|
|
642
|
-
* `
|
|
643
|
-
*
|
|
644
|
-
*
|
|
641
|
+
* Per Claude Code's settings reference: `modelPicker` is an OBJECT with an
|
|
642
|
+
* `options` array. Each option has `{ model, label?, description? }`.
|
|
643
|
+
* Scope is User-or-managed (settings.json is in scope). The `options`
|
|
644
|
+
* array preserves the operator's pick order from `userSelected.models`.
|
|
645
645
|
*
|
|
646
646
|
* Behavior:
|
|
647
647
|
* - Reads settings.json; preserves every other field (env, mcpServers,
|
|
648
648
|
* permissions, hooks, etc.).
|
|
649
|
-
* - Writes `modelPicker
|
|
649
|
+
* - Writes `modelPicker = { options: [{ model, label }] }`.
|
|
650
650
|
* - Filters out picks the live gateway rejects (same stale-ID contract as
|
|
651
651
|
* `applyModelOverrides`); the surviving picks fill the picker.
|
|
652
|
-
* - Empty pick list → writes `modelPicker: []` so
|
|
653
|
-
*
|
|
652
|
+
* - Empty pick list → writes `modelPicker: { options: [] }` so Claude
|
|
653
|
+
* Code falls back to its built-in picker.
|
|
654
654
|
* - Atomic replace via temp-file + rename (matches `applyModels`).
|
|
655
655
|
* - Refuses to overwrite a corrupt settings.json.
|
|
656
656
|
* - When `settingsJsonPath === null`, returns a no-op (tests).
|
|
@@ -663,7 +663,7 @@ export function deriveModelLabel(modelId, profile) {
|
|
|
663
663
|
* }} opts
|
|
664
664
|
* @returns {{
|
|
665
665
|
* wrote: boolean,
|
|
666
|
-
*
|
|
666
|
+
* options: Array<{model: string, label: string, description?: string}>,
|
|
667
667
|
* skippedStale: string[],
|
|
668
668
|
* settingsPath: string|null,
|
|
669
669
|
* }}
|
|
@@ -673,13 +673,20 @@ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, l
|
|
|
673
673
|
? join(homedir(), '.claude', 'settings.json')
|
|
674
674
|
: settingsJsonPath;
|
|
675
675
|
if (path === null) {
|
|
676
|
-
return { wrote: false,
|
|
676
|
+
return { wrote: false, options: [], skippedStale: [], settingsPath: null };
|
|
677
677
|
}
|
|
678
678
|
const live = new Set(Array.isArray(liveIds) ? liveIds : []);
|
|
679
679
|
const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
680
680
|
const surviving = live.size === 0 ? picks : picks.filter((id) => live.has(id));
|
|
681
681
|
const skipped = live.size === 0 ? [] : picks.filter((id) => !live.has(id));
|
|
682
|
-
const
|
|
682
|
+
const options = surviving.map((id) => {
|
|
683
|
+
const profile = profiles?.[id];
|
|
684
|
+
const label = deriveModelLabel(id, profile);
|
|
685
|
+
const option = { model: id, label };
|
|
686
|
+
const description = profile && typeof profile.description === 'string' && profile.description.trim();
|
|
687
|
+
if (description) option.description = description.trim();
|
|
688
|
+
return option;
|
|
689
|
+
});
|
|
683
690
|
|
|
684
691
|
let settings = {};
|
|
685
692
|
if (existsSync(path)) {
|
|
@@ -687,12 +694,12 @@ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, l
|
|
|
687
694
|
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
688
695
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed;
|
|
689
696
|
} catch {
|
|
690
|
-
return { wrote: false,
|
|
697
|
+
return { wrote: false, options: [], skippedStale: skipped, settingsPath: path };
|
|
691
698
|
}
|
|
692
699
|
}
|
|
693
|
-
settings.modelPicker =
|
|
700
|
+
settings.modelPicker = { options };
|
|
694
701
|
writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
|
|
695
|
-
return { wrote: true,
|
|
702
|
+
return { wrote: true, options, skippedStale: skipped, settingsPath: path };
|
|
696
703
|
}
|
|
697
704
|
|
|
698
705
|
// ── F-190 / IMP-017 refresh + operator-override preservation ────────────────
|
|
@@ -1654,7 +1661,7 @@ export async function run(name, args, isHelpRequest) {
|
|
|
1654
1661
|
console.log(chalk.yellow(` Settings sync skipped ${sync.skippedStale.length} stale id(s): ${sync.skippedStale.join(', ')}`));
|
|
1655
1662
|
}
|
|
1656
1663
|
if (picker.wrote) {
|
|
1657
|
-
console.log(chalk.dim(` /model picker populated with ${picker.
|
|
1664
|
+
console.log(chalk.dim(` /model picker populated with ${picker.options.length} entr${picker.options.length === 1 ? 'y' : 'ies'}`));
|
|
1658
1665
|
}
|
|
1659
1666
|
}
|
|
1660
1667
|
return true;
|
package/cli/commands/util.mjs
CHANGED
|
@@ -182,17 +182,14 @@ export async function runTestGate() {
|
|
|
182
182
|
|
|
183
183
|
export async function run(name, args, isHelpRequest) {
|
|
184
184
|
switch (name) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
await runUpdate(args, {});
|
|
194
|
-
}
|
|
195
|
-
break;
|
|
185
|
+
// NOTE: 'update' is intentionally NOT routed through this dispatcher.
|
|
186
|
+
// `cli/bin.mjs` dispatches 'install' / 'update' directly to
|
|
187
|
+
// `cli/commands/install.mjs`, which owns both commands (they share
|
|
188
|
+
// the same code path). The legacy branch below used to import
|
|
189
|
+
// `runUpdate` from `./install.mjs`, but that module never exported
|
|
190
|
+
// `runUpdate` — the import would throw at runtime. The branch was
|
|
191
|
+
// dead code, deleted in v10.19.6 as part of the `bizar update` audit
|
|
192
|
+
// fix.
|
|
196
193
|
|
|
197
194
|
case 'audit':
|
|
198
195
|
if (isHelpRequest) showAuditHelp();
|
package/cli/provision.mjs
CHANGED
|
@@ -1431,10 +1431,11 @@ if (import.meta.url === `file://${process.argv[1]}`) {
|
|
|
1431
1431
|
});
|
|
1432
1432
|
}
|
|
1433
1433
|
|
|
1434
|
-
// Back-compat alias — `bizar update` historically
|
|
1435
|
-
//
|
|
1436
|
-
// `
|
|
1437
|
-
// `cli/commands/install.mjs`
|
|
1438
|
-
//
|
|
1434
|
+
// Back-compat alias — `bizar update` historically called `runUpdate(args)`;
|
|
1435
|
+
// install + update now share `runProvision({ mode: 'update', ... })`.
|
|
1436
|
+
// `runUpdate` is retained as an importable export for external SDK
|
|
1437
|
+
// consumers (re-exported from `cli/update.mjs`); `cli/commands/install.mjs`
|
|
1438
|
+
// no longer imports it — its `update()` command routes through `parseFlags`
|
|
1439
|
+
// → `runInstaller({ mode: 'update', ... })` instead.
|
|
1439
1440
|
export const runUpdate = (subargs, opts = {}) =>
|
|
1440
1441
|
runProvision({ ...opts, mode: 'update', subargs });
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sessionstart-model-sync.mjs — Claude Code SessionStart hook.
|
|
4
|
+
*
|
|
5
|
+
* Re-applies the operator's `userSelected.models` block (from
|
|
6
|
+
* `~/.config/bizar/config/claude/model-router.json`) into the three
|
|
7
|
+
* Claude Code settings keys that the operator owns:
|
|
8
|
+
*
|
|
9
|
+
* - modelPicker → { options: [{ model, label }] } in user pick order
|
|
10
|
+
* - modelOverrides → { <id>: <id> } self-map for every live pick
|
|
11
|
+
* - model → reset to the first user pick if the current value
|
|
12
|
+
* starts with `claude-` (the dead gateway namespace —
|
|
13
|
+
* the live gateway rejects it with model_not_found)
|
|
14
|
+
*
|
|
15
|
+
* Why this hook exists:
|
|
16
|
+
* Claude Code's `/model` picker rewrites `~/.claude/settings.json` on
|
|
17
|
+
* every user pick. The rewrite preserves most keys but drops `modelPicker`
|
|
18
|
+
* and rewrites `model` to whatever the gateway returned (often the dead
|
|
19
|
+
* `claude-<provider>/<model>[1m]` shape). Without this hook the operator
|
|
20
|
+
* loses their picker list between every Claude Code session.
|
|
21
|
+
*
|
|
22
|
+
* Scope guarantee:
|
|
23
|
+
* This hook ONLY touches `modelPicker`, `modelOverrides`, and `model`.
|
|
24
|
+
* Env, mcpServers, permissions, hooks, and every other operator key is
|
|
25
|
+
* left untouched. The source of truth for picks
|
|
26
|
+
* (`~/.config/bizar/config/claude/model-router.json`) is also untouched.
|
|
27
|
+
*
|
|
28
|
+
* Failure policy:
|
|
29
|
+
* This is an advisory hook. Any failure (missing router, malformed JSON,
|
|
30
|
+
* unwritable settings.json, etc.) is logged to
|
|
31
|
+
* `~/.config/bizar/hook-logs/model-sync-DATE.jsonl` and swallowed. The
|
|
32
|
+
* hook always exits 0 — the operator's existing picks survive whatever
|
|
33
|
+
* Claude Code wrote last, and a broken sync must not block session start.
|
|
34
|
+
*
|
|
35
|
+
* Claude Code SessionStart input:
|
|
36
|
+
* { session_id, transcript_path, cwd, hook_event_name, source }
|
|
37
|
+
*
|
|
38
|
+
* Claude Code SessionStart output:
|
|
39
|
+
* { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext } }
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
'use strict';
|
|
43
|
+
|
|
44
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
45
|
+
import { dirname, join } from 'node:path';
|
|
46
|
+
import { homedir } from 'node:os';
|
|
47
|
+
|
|
48
|
+
const HOOK_LOG_DIR = join(homedir(), '.config', 'bizar', 'hook-logs');
|
|
49
|
+
|
|
50
|
+
function readJsonIfObject(path) {
|
|
51
|
+
if (!existsSync(path)) return null;
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
54
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readRouterPath() {
|
|
61
|
+
const envOverride = process.env.BIZAR_MODEL_ROUTER_CONFIG;
|
|
62
|
+
if (envOverride && typeof envOverride === 'string' && envOverride.trim()) {
|
|
63
|
+
return envOverride;
|
|
64
|
+
}
|
|
65
|
+
return join(homedir(), '.config', 'bizar', 'config', 'claude', 'model-router.json');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readSettingsPath() {
|
|
69
|
+
return join(homedir(), '.claude', 'settings.json');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Mirrors `cli/commands/models.mjs#deriveModelLabel` without the profile
|
|
73
|
+
// override branch. The picker label is meant to be derived from the model
|
|
74
|
+
// id; the profile `name`/`displayName` is used elsewhere in `applyModelPicker`
|
|
75
|
+
// only when present, but for our session-start re-apply the id-derived label
|
|
76
|
+
// is sufficient and matches the helper's behaviour for every live id in the
|
|
77
|
+
// router file.
|
|
78
|
+
function deriveModelLabel(modelId) {
|
|
79
|
+
const id = String(modelId || '').trim();
|
|
80
|
+
if (!id) return '';
|
|
81
|
+
const slash = id.indexOf('/');
|
|
82
|
+
const tail = slash >= 0 ? id.slice(slash + 1) : id;
|
|
83
|
+
return tail
|
|
84
|
+
.replace(/[\\/]+/g, ' ')
|
|
85
|
+
.replace(/[-_]+/g, ' ')
|
|
86
|
+
.replace(/:/g, ' ')
|
|
87
|
+
.replace(/\s+/g, ' ')
|
|
88
|
+
.trim();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function atomicWriteJson(path, value) {
|
|
92
|
+
const dir = dirname(path);
|
|
93
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
94
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
95
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
|
|
96
|
+
renameSync(tmp, path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function logEvent(entry) {
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(HOOK_LOG_DIR, { recursive: true });
|
|
102
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
103
|
+
appendFileSync(
|
|
104
|
+
join(HOOK_LOG_DIR, `model-sync-${today}.jsonl`),
|
|
105
|
+
JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n',
|
|
106
|
+
);
|
|
107
|
+
} catch {
|
|
108
|
+
/* best-effort */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function buildPayload(appliedCount, modelAfter) {
|
|
113
|
+
const note = appliedCount > 0
|
|
114
|
+
? `Bizar reapplied ${appliedCount} model picker option(s) to ~/.claude/settings.json (model=${modelAfter}).`
|
|
115
|
+
: '';
|
|
116
|
+
return {
|
|
117
|
+
hookSpecificOutput: {
|
|
118
|
+
hookEventName: 'SessionStart',
|
|
119
|
+
additionalContext: note,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function syncOnce() {
|
|
125
|
+
const routerPath = readRouterPath();
|
|
126
|
+
const router = readJsonIfObject(routerPath);
|
|
127
|
+
const userSelected = router && typeof router === 'object' ? router.userSelected : null;
|
|
128
|
+
const models = userSelected && Array.isArray(userSelected.models) ? userSelected.models : [];
|
|
129
|
+
const profiles = userSelected && userSelected.profiles && typeof userSelected.profiles === 'object'
|
|
130
|
+
? userSelected.profiles
|
|
131
|
+
: {};
|
|
132
|
+
const liveIds = models.filter((id) => typeof id === 'string' && id.trim());
|
|
133
|
+
if (liveIds.length === 0) {
|
|
134
|
+
return { applied: 0, modelAfter: null, skipped: 'no-userSelected' };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const settingsPath = readSettingsPath();
|
|
138
|
+
const settings = readJsonIfObject(settingsPath) || {};
|
|
139
|
+
const before = {
|
|
140
|
+
model: typeof settings.model === 'string' ? settings.model : null,
|
|
141
|
+
modelPicker: settings.modelPicker || null,
|
|
142
|
+
modelOverrides: settings.modelOverrides || null,
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const options = liveIds.map((id) => {
|
|
146
|
+
const option = { model: id, label: deriveModelLabel(id) };
|
|
147
|
+
const profile = profiles[id];
|
|
148
|
+
if (profile && typeof profile.description === 'string' && profile.description.trim()) {
|
|
149
|
+
option.description = profile.description.trim();
|
|
150
|
+
}
|
|
151
|
+
return option;
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
settings.modelPicker = { options };
|
|
155
|
+
settings.modelOverrides = Object.fromEntries(liveIds.map((id) => [id, id]));
|
|
156
|
+
|
|
157
|
+
let modelChanged = false;
|
|
158
|
+
if (typeof settings.model === 'string' && settings.model.startsWith('claude-')) {
|
|
159
|
+
settings.model = liveIds[0];
|
|
160
|
+
modelChanged = true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
atomicWriteJson(settingsPath, settings);
|
|
164
|
+
|
|
165
|
+
const after = {
|
|
166
|
+
model: typeof settings.model === 'string' ? settings.model : null,
|
|
167
|
+
modelPickerOptions: options.length,
|
|
168
|
+
modelOverridesCount: Object.keys(settings.modelOverrides).length,
|
|
169
|
+
modelChanged,
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
logEvent({ event: 'sessionstart-model-sync', routerPath, before, after });
|
|
173
|
+
return { applied: options.length, modelAfter: after.model, modelChanged };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function run() {
|
|
177
|
+
let raw = '';
|
|
178
|
+
process.stdin.setEncoding('utf8');
|
|
179
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
180
|
+
process.stdin.on('end', () => {
|
|
181
|
+
let input = {};
|
|
182
|
+
try { input = JSON.parse(raw || '{}'); } catch { input = {}; }
|
|
183
|
+
const sessionId = String(input.session_id || '');
|
|
184
|
+
const source = String(input.source || 'startup');
|
|
185
|
+
try {
|
|
186
|
+
const result = syncOnce();
|
|
187
|
+
const payload = buildPayload(result.applied, result.modelAfter);
|
|
188
|
+
process.stdout.write(JSON.stringify(payload) + '\n');
|
|
189
|
+
} catch (err) {
|
|
190
|
+
logEvent({
|
|
191
|
+
event: 'sessionstart-model-sync-error',
|
|
192
|
+
sessionId: sessionId || null,
|
|
193
|
+
source,
|
|
194
|
+
error: err && err.message ? err.message : String(err),
|
|
195
|
+
});
|
|
196
|
+
// Advisory hook — never block session start on a sync failure.
|
|
197
|
+
process.stdout.write('{}\n');
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
run();
|
package/package.json
CHANGED