@polderlabs/bizar 4.4.6 → 4.4.8

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.
@@ -29,8 +29,8 @@
29
29
  }
30
30
  })();
31
31
  </script>
32
- <script type="module" crossorigin src="/assets/main-C4Dq4wTz.js"></script>
33
- <link rel="modulepreload" crossorigin href="/assets/mobile-CaZ0gEeJ.js">
32
+ <script type="module" crossorigin src="/assets/main-CEazNxxy.js"></script>
33
+ <link rel="modulepreload" crossorigin href="/assets/mobile-Dl1q7Cyq.js">
34
34
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
35
35
  <link rel="stylesheet" crossorigin href="/assets/main-Nq8Dq3VR.css">
36
36
  </head>
@@ -12,8 +12,8 @@
12
12
  <link rel="preconnect" href="https://fonts.googleapis.com" />
13
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
14
14
  <link rel="stylesheet" href="https://rsms.me/inter/inter.css" />
15
- <script type="module" crossorigin src="/assets/mobile-CQTXbuHq.js"></script>
16
- <link rel="modulepreload" crossorigin href="/assets/mobile-CaZ0gEeJ.js">
15
+ <script type="module" crossorigin src="/assets/mobile-DSb-t42Y.js"></script>
16
+ <link rel="modulepreload" crossorigin href="/assets/mobile-Dl1q7Cyq.js">
17
17
  <link rel="stylesheet" crossorigin href="/assets/mobile-CsZQAswA.css">
18
18
  </head>
19
19
  <body>
@@ -199,6 +199,19 @@ function parseJsxValue(src) {
199
199
  }
200
200
  while (i < len) {
201
201
  skipWs();
202
+ // v4.4.8 — After consuming a comma + whitespace, check for the
203
+ // closer before recursing. Without this, an array like `[a, b, ]`
204
+ // would call parseValue on `]`, which falls through to the
205
+ // bareword-identifier branch and returns `{__ident: ''}` as a
206
+ // phantom element. The trailing-comma-then-closer case was the
207
+ // user-visible bug in the v3-to-v4-consolidation glyph: the
208
+ // FileTree's last entry was the bareword phantom, which then
209
+ // crashed the React renderer (components.tsx:609 tried to read
210
+ // `t.bg` on undefined).
211
+ if (arr && src[i] === closer) {
212
+ i += 1;
213
+ return items;
214
+ }
202
215
  if (arr) {
203
216
  items.push(parseValue());
204
217
  } else {
@@ -49,6 +49,142 @@ import {
49
49
  Mockup,
50
50
  } from './components';
51
51
 
52
+ // ---------------------------------------------------------------------------
53
+ // BlockErrorBoundary — v4.4.8
54
+ //
55
+ // A single broken block (e.g. a FileTree whose `data.entries` ended up
56
+ // not being an array because the parser silently produced a phantom
57
+ // bareword entry) used to throw inside React, which propagated up and
58
+ // blanked the whole canvas. The boundary catches throws per-block and
59
+ // renders an inline error card instead. The boundary also exposes its
60
+ // errors via a callback so we can surface them in the top-of-page
61
+ // "render errors" banner.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface BlockErrorBoundaryProps {
65
+ blockId: string;
66
+ blockType: string;
67
+ children: React.ReactNode;
68
+ onError: (blockId: string, blockType: string, err: Error, info: React.ErrorInfo) => void;
69
+ }
70
+
71
+ interface BlockErrorBoundaryState {
72
+ err: Error | null;
73
+ }
74
+
75
+ class BlockErrorBoundary extends React.Component<BlockErrorBoundaryProps, BlockErrorBoundaryState> {
76
+ state: BlockErrorBoundaryState = { err: null };
77
+ static getDerivedStateFromError(err: Error): BlockErrorBoundaryState {
78
+ return { err };
79
+ }
80
+ componentDidCatch(err: Error, info: React.ErrorInfo): void {
81
+ try {
82
+ this.props.onError(this.props.blockId, this.props.blockType, err, info);
83
+ } catch {
84
+ /* ignore — the boundary itself must not throw */
85
+ }
86
+ }
87
+ render(): React.ReactNode {
88
+ if (this.state.err) {
89
+ return (
90
+ <div
91
+ className="glyph-block-error"
92
+ role="alert"
93
+ style={{
94
+ border: '1px solid var(--error, #f85149)',
95
+ background: 'rgba(248, 81, 73, 0.06)',
96
+ borderRadius: 8,
97
+ padding: '12px 14px',
98
+ margin: '12px 0',
99
+ color: 'var(--text)',
100
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
101
+ fontSize: 12,
102
+ lineHeight: 1.55,
103
+ }}
104
+ >
105
+ <strong style={{ color: 'var(--error, #f85149)', fontFamily: 'var(--font-sans, system-ui, sans-serif)', display: 'block', marginBottom: 4 }}>
106
+ {this.props.blockType} block crashed
107
+ </strong>
108
+ <div style={{ color: 'var(--text-muted)', marginBottom: 6 }}>
109
+ block id: <code>{this.props.blockId}</code>
110
+ </div>
111
+ <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', color: 'var(--text)' }}>
112
+ {this.state.err.message}
113
+ </pre>
114
+ </div>
115
+ );
116
+ }
117
+ return this.props.children;
118
+ }
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Block validation — v4.4.8
123
+ //
124
+ // Before handing block data to the component switch, verify that the
125
+ // shape matches the prop contract. Returns `{ ok: true }` when the
126
+ // block can be rendered, or `{ ok: false, error }` when a field is
127
+ // missing or wrong type. The renderer uses the error to skip the
128
+ // component and surface an inline message instead of throwing.
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function validateBlock(b: Block): { ok: true } | { ok: false; error: string } {
132
+ const data = (b.data ?? {}) as Record<string, unknown>;
133
+ switch (b.type) {
134
+ case 'RichText':
135
+ return { ok: true };
136
+ case 'Callout':
137
+ return { ok: true };
138
+ case 'Checklist':
139
+ if (!Array.isArray(data.items)) return { ok: false, error: 'Checklist requires data.items to be an array' };
140
+ return { ok: true };
141
+ case 'Table':
142
+ if (!Array.isArray(data.columns)) return { ok: false, error: 'Table requires data.columns to be an array' };
143
+ if (!Array.isArray(data.rows)) return { ok: false, error: 'Table requires data.rows to be an array' };
144
+ if (data.rows.length > 0 && !Array.isArray(data.rows[0])) return { ok: false, error: 'Table.data.rows[0] must be an array (cell array)' };
145
+ return { ok: true };
146
+ case 'CodeTabs':
147
+ if (!Array.isArray(data.tabs)) return { ok: false, error: 'CodeTabs requires data.tabs to be an array' };
148
+ return { ok: true };
149
+ case 'Decision':
150
+ if (!Array.isArray(data.options)) return { ok: false, error: 'Decision requires data.options to be an array' };
151
+ return { ok: true };
152
+ case 'OpenQuestions':
153
+ if (!Array.isArray(data.questions)) return { ok: false, error: 'OpenQuestions requires data.questions to be an array' };
154
+ return { ok: true };
155
+ case 'FileTree':
156
+ if (!Array.isArray(data.entries)) return { ok: false, error: 'FileTree requires data.entries to be an array' };
157
+ // v4.4.8 — also catch the phantom bareword entry the parser used
158
+ // to emit (the v3-to-v4-consolidation glyph crash). An entry that
159
+ // is not an object means the parser miscounted braces.
160
+ for (const e of data.entries) {
161
+ if (e === null || typeof e !== 'object' || Array.isArray(e)) {
162
+ return { ok: false, error: `FileTree contains a malformed entry: ${JSON.stringify(e)}` };
163
+ }
164
+ }
165
+ return { ok: true };
166
+ case 'Diff':
167
+ if (typeof data.before !== 'string') return { ok: false, error: 'Diff requires data.before to be a string' };
168
+ if (typeof data.after !== 'string') return { ok: false, error: 'Diff requires data.after to be a string' };
169
+ return { ok: true };
170
+ case 'Stat':
171
+ if (data.label === undefined || data.label === null) return { ok: false, error: 'Stat requires data.label' };
172
+ if (data.value === undefined || data.value === null) return { ok: false, error: 'Stat requires data.value' };
173
+ return { ok: true };
174
+ case 'Workflow':
175
+ if (!Array.isArray(data.steps)) return { ok: false, error: 'Workflow requires data.steps to be an array' };
176
+ return { ok: true };
177
+ case 'Mockup':
178
+ if (typeof data.html !== 'string') return { ok: false, error: 'Mockup requires data.html to be a string' };
179
+ return { ok: true };
180
+ case 'Diagram':
181
+ if (typeof data.dataHtml !== 'string') return { ok: false, error: 'Diagram requires data.dataHtml to be a string' };
182
+ return { ok: true };
183
+ default:
184
+ return { ok: false, error: `Unknown block type: ${(b as { type: string }).type}` };
185
+ }
186
+ }
187
+
52
188
  // ---------------------------------------------------------------------------
53
189
  // Types — match the server compiler output
54
190
  // ---------------------------------------------------------------------------
@@ -72,7 +208,7 @@ interface CompiledGlyph {
72
208
  slug: string;
73
209
  frontmatter: Record<string, unknown>;
74
210
  blocks: Block[];
75
- errors: string[];
211
+ errors: Array<{ line?: number; message: string }>;
76
212
  compiledAt: string;
77
213
  }
78
214
 
@@ -162,6 +298,25 @@ export function GlyphRenderer({ slug, onClose, onCommentAdded }: Props) {
162
298
  // Active pin (expanded thread view)
163
299
  const [activePin, setActivePin] = useState<string | null>(null);
164
300
 
301
+ // v4.4.8 — Per-block render errors caught by BlockErrorBoundary. The
302
+ // boundary calls onError(err) which pushes into this array. We render
303
+ // the array at the top of the canvas in a prominent red banner so the
304
+ // user isn't left staring at a blank screen wondering what happened.
305
+ const [blockErrors, setBlockErrors] = useState<
306
+ Array<{ blockId: string; blockType: string; message: string }>
307
+ >([]);
308
+ const reportBlockError = React.useCallback(
309
+ (blockId: string, blockType: string, err: Error) => {
310
+ setBlockErrors((prev) => {
311
+ // dedupe by blockId so the same block crashing twice doesn't
312
+ // duplicate the entry
313
+ if (prev.some((e) => e.blockId === blockId)) return prev;
314
+ return [...prev, { blockId, blockType, message: err.message || String(err) }];
315
+ });
316
+ },
317
+ [],
318
+ );
319
+
165
320
  const canvasRef = useRef<HTMLDivElement>(null);
166
321
 
167
322
  // Section grouping — memoized so we don't re-walk the block array on every render
@@ -289,10 +444,58 @@ export function GlyphRenderer({ slug, onClose, onCommentAdded }: Props) {
289
444
  setFullscreen((v) => !v);
290
445
  }
291
446
 
292
- // Render a single block
447
+ // Render a single block — wrapped in a BlockErrorBoundary so a single
448
+ // broken block (e.g. wrong data shape from a parser bug) shows an inline
449
+ // error card instead of blanking the whole canvas.
293
450
  const renderBlock = (b: Block) => {
294
451
  const id = b.id;
295
452
  const data = (b.data ?? {}) as Record<string, unknown>;
453
+ // Validate data shape BEFORE handing to the component switch. A
454
+ // validation failure shows an inline error card immediately, no
455
+ // need for the boundary to catch it.
456
+ const validation = validateBlock(b);
457
+ if (!validation.ok) {
458
+ return (
459
+ <div
460
+ key={id}
461
+ id={id}
462
+ className="glyph-block-error"
463
+ role="alert"
464
+ style={{
465
+ border: '1px solid var(--error, #f85149)',
466
+ background: 'rgba(248, 81, 73, 0.06)',
467
+ borderRadius: 8,
468
+ padding: '12px 14px',
469
+ margin: '12px 0',
470
+ color: 'var(--text)',
471
+ fontFamily: 'var(--font-mono, ui-monospace, monospace)',
472
+ fontSize: 12,
473
+ lineHeight: 1.55,
474
+ }}
475
+ >
476
+ <strong style={{ color: 'var(--error, #f85149)', fontFamily: 'var(--font-sans, system-ui, sans-serif)', display: 'block', marginBottom: 4 }}>
477
+ {b.type} block invalid
478
+ </strong>
479
+ <div style={{ color: 'var(--text-muted)', marginBottom: 6 }}>
480
+ block id: <code>{id}</code>
481
+ </div>
482
+ <pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>{validation.error}</pre>
483
+ </div>
484
+ );
485
+ }
486
+ // Wrap the actual render in an error boundary so even runtime
487
+ // errors (bad component props, undefined data, etc.) don't crash
488
+ // the whole canvas.
489
+ return (
490
+ <BlockErrorBoundary key={id} blockId={id} blockType={b.type} onError={reportBlockError}>
491
+ {renderBlockInner(b, id, data)}
492
+ </BlockErrorBoundary>
493
+ );
494
+ };
495
+
496
+ // Inner renderer — split out so the outer renderBlock can validate +
497
+ // wrap in an error boundary without nesting the switch in the JSX tree.
498
+ const renderBlockInner = (b: Block, id: string, data: Record<string, unknown>) => {
296
499
  switch (b.type) {
297
500
  case 'RichText':
298
501
  return <RichText key={id} id={id}>{b.childrenMarkdown ?? ''}</RichText>;
@@ -506,13 +709,41 @@ export function GlyphRenderer({ slug, onClose, onCommentAdded }: Props) {
506
709
  </div>
507
710
  </header>
508
711
 
509
- {/* Compiler warnings */}
510
- {compiled.errors?.length > 0 && (
511
- <div className="glyph-errors">
512
- <strong>Compiler warnings:</strong>
513
- <ul>
514
- {compiled.errors.map((e, i) => (
515
- <li key={i}>{e}</li>
712
+ {/* v4.4.8 Render errors banner. Two layers:
713
+ 1. Compiler warnings from the MDX parser (e.g. unknown
714
+ tag, unclosed block). Lightweight, yellow border.
715
+ 2. Block render errors caught by BlockErrorBoundary. These
716
+ are the "one block crashed but the rest still rendered"
717
+ cases. Red border, prominent, listed first so the user
718
+ sees them before scrolling. */}
719
+ {(blockErrors.length > 0 || compiled.errors?.length > 0) && (
720
+ <div
721
+ className="glyph-render-errors"
722
+ role="alert"
723
+ style={{
724
+ border: '1px solid var(--error, #f85149)',
725
+ background: 'rgba(248, 81, 73, 0.08)',
726
+ borderRadius: 8,
727
+ padding: '12px 16px',
728
+ margin: '0 0 16px 0',
729
+ color: 'var(--text)',
730
+ }}
731
+ >
732
+ <strong style={{ color: 'var(--error, #f85149)', display: 'block', marginBottom: 8 }}>
733
+ {blockErrors.length > 0
734
+ ? `${blockErrors.length} block${blockErrors.length === 1 ? '' : 's'} failed to render`
735
+ : 'Compiler warnings'}
736
+ </strong>
737
+ <ul style={{ margin: 0, paddingLeft: 20, fontFamily: 'var(--font-mono, ui-monospace, monospace)', fontSize: 12, lineHeight: 1.6 }}>
738
+ {blockErrors.map((e, i) => (
739
+ <li key={`be-${i}`}>
740
+ <strong>{e.blockType}</strong> (<code>{e.blockId}</code>): {e.message}
741
+ </li>
742
+ ))}
743
+ {compiled.errors?.map((e, i) => (
744
+ <li key={`ce-${i}`}>
745
+ {e.line ? `line ${e.line}: ` : ''}{e.message}
746
+ </li>
516
747
  ))}
517
748
  </ul>
518
749
  </div>
package/cli/bin.mjs CHANGED
@@ -153,59 +153,68 @@ function showExportHelp() {
153
153
 
154
154
  function showInstallHelp() {
155
155
  console.log(`
156
- bizar install — Run the canonical BizarHarness installer
156
+ bizar install — Run the unified BizarHarness installer
157
157
 
158
158
  Usage:
159
- bizar install
159
+ bizar install Install (or refresh) every component
160
+ bizar install --dry-run Print what would happen, change nothing
161
+ bizar install --force Overwrite existing files
162
+ bizar install --help Show this help
160
163
 
161
164
  Description:
162
- v3.20.11+ — thin wrapper around ./install.sh. The bash script
163
- auto-installs every system dep (uv, python3.12, chrome-headless-shell
164
- + runtime libs, jq), installs browser-harness via uv, syncs agents /
165
- commands / hooks / skills into ~/.config/opencode/, configures
166
- opencode.json + the Bizar plugin, and prints a single status banner.
167
-
168
- No API key collection, no interactive prompts, no opencode restart
169
- the installer is safe to re-run anytime.
170
-
171
- Run from a fresh git clone instead: \`cd BizarHarness && ./install.sh\`
165
+ v4.4.7+ — unified installer. Same code path as 'bizar update'; the
166
+ difference is just mode=install vs mode=update. Every step is
167
+ idempotent running this twice is safe.
168
+
169
+ 1. Installs @polderlabs/bizar via npm (skipped if already current).
170
+ 2. Shells to ./install.sh for platform-specific system deps (uv,
171
+ python3.12, jq, gh on Linux; brew on macOS) + service registration
172
+ (systemd / launchd / Task Scheduler).
173
+ 3. Syncs agent files, slash commands, and bundled skills into
174
+ ~/.config/opencode/.
175
+ 4. Copies plugins/bizar/ from the npm install into
176
+ ~/.config/opencode/plugins/bizar/ (preserves dev symlinks).
177
+ 5. Patches ~/.config/opencode/opencode.json with the Bizar plugin
178
+ entry (skipped if already present).
179
+ 6. Runs 'bizar doctor' as a post-install health check.
180
+
181
+ No API key collection, no interactive prompts.
172
182
  `);
173
183
  }
174
184
 
175
185
  function showUpdateHelp() {
176
186
  console.log(`
177
- bizar update — Update opencode, bizar, bizar-dash, and/or bizar-plugin
187
+ bizar update — Update opencode + @polderlabs/bizar (which bundles the
188
+ plugin and dashboard). Detects what's installed and only touches what's
189
+ missing or out of date.
178
190
 
179
191
  Usage:
180
192
  bizar update Update EVERYTHING (default; auto-kills + restarts)
181
- bizar update --pick Interactive picker (legacy per-component UI)
182
- bizar update opencode bizar dash Update specific components
183
193
  bizar update --no-restart Don't auto-restart the dashboard after update
184
194
  bizar update --dry-run Print what would happen, change nothing
195
+ bizar update --force Override .bizar/PRE_PUSH_NOTES.md blockers
196
+ bizar update --yes Same as --force, but named for one-line scripts
185
197
  bizar update --help Show this help
186
198
 
187
- Components:
188
- opencode the opencode CLI itself
189
- bizar @polderlabs/bizar (this CLI + installer + agents + rules)
190
- dash @polderlabs/bizar-dash (web dashboard + TUI)
191
- plugin @polderlabs/bizar-plugin (opencode plugin)
199
+ Components updated:
200
+ opencode-ai the opencode CLI itself
201
+ @polderlabs/bizar this CLI + dashboard + plugin (one package)
192
202
 
193
- Behavior (v3.15.0+):
194
- Default = automatic. Updates all 4 components without prompting.
195
- Any missing package is auto-installed so a broken install gets
196
- repaired in one shot.
203
+ Behavior (v4.4.7+):
204
+ Single unified provisioner. 'bizar install' and 'bizar update' are
205
+ the same code path with different mode flags. Every step is
206
+ idempotent re-running is safe.
197
207
  • Detects running Bizar instances (background service daemon, web
198
208
  dashboard) by reading ~/.config/bizar/{service,dashboard}.pid and
199
209
  cleans up any stale or empty PID files.
200
- • Auto-kills running instances with a brief notice (use --pick to
201
- be prompted first instead).
210
+ • Auto-kills running instances with a brief notice.
202
211
  • Sends SIGTERM, waits up to 5s, escalates to SIGKILL if needed.
203
212
  • Re-runs the install script so the deployed plugin source matches
204
213
  the just-upgraded npm version (avoids the version-skew trap).
205
- • If the dashboard was running and bizar or dash was updated,
206
- spawns a fresh detached dashboard process with the new code
207
- (skipped with --no-restart).
208
- • Runs \`bizar doctor\` after a successful update to catch config
214
+ • If the dashboard was running and bizar was updated, spawns a
215
+ fresh detached dashboard process with the new code (skipped with
216
+ --no-restart).
217
+ • Runs 'bizar doctor' after a successful update to catch config
209
218
  regressions before opencode tries to start.
210
219
 
211
220
  Examples:
package/cli/install.mjs CHANGED
@@ -203,60 +203,22 @@ export async function installPluginFromGlobal(opts = {}) {
203
203
  }
204
204
 
205
205
  /**
206
- * runInstaller — v3.20.11 thin wrapper.
206
+ * runInstaller — v4.4.7 thin wrapper around the unified provisioner.
207
207
  *
208
- * As of v3.20.11, `bizar install` is a thin wrapper around the canonical
209
- * `install.sh` script at the repo root. The bash script handles all the
210
- * heavy lifting: system-dep installation (uv, python3.12, chrome-headless-shell
211
- * + runtime libs, jq), browser-harness via uv, Chrome lifecycle, agent /
212
- * command / hook / skill sync, opencode.json merging, install-state.
208
+ * `bizar install` and `bizar update` are now the SAME code path with
209
+ * different `mode` flags. Everything (deps, plugin copy, opencode.json
210
+ * patching, service registration, agent files, skills, doctor) is
211
+ * owned by `cli/provision.mjs:runProvision`. This file just parses the
212
+ * flags and forwards.
213
213
  *
214
- * Why a thin wrapper instead of the old TUI:
215
- * - One source of truth. The bash script is also what `git clone` users
216
- * run (`./install.sh`), what `bizar update` re-runs, and what the npm
217
- * postinstall hook invokes. Keeping a parallel Node implementation
218
- * guarantees the two will diverge.
219
- * - No interactive prompts. The TUI asked for API keys, restart
220
- * confirmation, and component selection — none of which belong in a
221
- * `npm i -g @polderlabs/bizar` install (no TTY, secrets stay local).
222
- * - Cross-platform. The bash script already detects Windows and falls
223
- * back to the npm-based path; re-implementing that in Node is busywork.
224
- *
225
- * Returns the install.sh exit code (0 = success, 1 = partial failure).
226
- * On platforms where bash isn't available (rare — Windows without WSL),
227
- * falls back to running the npm-based path directly.
214
+ * Backward-compatible the public exports (`runInstaller`,
215
+ * `runPostInstall`, `installPluginFromGlobal`) still exist so that any
216
+ * external callers (including the postinstall npm hook) keep working.
217
+ * New code should call `runProvision({ mode: 'install' })` directly.
228
218
  */
229
- export async function runInstaller() {
230
- const { existsSync } = await import('node:fs');
231
- const { join } = await import('node:path');
232
- const { spawnSync } = await import('node:child_process');
233
-
234
- // __dirname is defined at module scope (see top of file)
235
- // cli/install.mjs → ../install.sh
236
- const installSh = join(__dirname, '..', 'install.sh');
237
-
238
- if (!existsSync(installSh)) {
239
- console.error(chalk.red(' ✗ install.sh not found at ' + installSh));
240
- console.error(chalk.dim(' Run `git clone https://github.com/DrB0rk/BizarHarness` first, then `cd BizarHarness && ./install.sh`.'));
241
- process.exit(1);
242
- }
243
-
244
- console.log(chalk.bold.hex('#6366f1')('\n BizarHarness installer (delegating to install.sh)\n'));
245
-
246
- const useBash = process.platform !== 'win32' || process.env.WSL_DISTRO_NAME;
247
- if (useBash) {
248
- const r = spawnSync('bash', [installSh], { stdio: 'inherit' });
249
- process.exit(r.status ?? 1);
250
- }
251
-
252
- // Windows without WSL — bash isn't available. The bash script has a
253
- // Windows fallback that uses npm-based install paths. Run the npm path
254
- // directly: install the BizarHarness npm packages + copy plugin from
255
- // global to ~/.config/opencode/plugins/bizar/. This is the same code
256
- // path install.sh uses for `platform == win32`.
257
- console.log(chalk.dim(' bash not available — running npm-based installer (Windows path)'));
258
- console.log('');
259
- await runPostInstall();
219
+ export async function runInstaller(opts = {}) {
220
+ const { runProvision } = await import('./provision.mjs');
221
+ return runProvision({ ...opts, mode: 'install' });
260
222
  }
261
223
 
262
224
  // ── Interactive prompts for optional packages ─────────────────────────────────