@polderlabs/bizar 10.19.3 → 10.19.5
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/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/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;
|
|
@@ -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