@bahulam/code 0.1.13 → 0.1.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bahulam/code",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Bahulam Code — abundance, in your terminal. CLI-first, reliability-first, sub-agents, 65.6% SWE-bench Verified.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,6 +40,9 @@ const CYAN = '\x1b[36m';
40
40
  const GREEN = '\x1b[32m';
41
41
  const YELLOW = '\x1b[33m';
42
42
 
43
+ const DEFAULT_BAHULAM_PLUGIN_REGISTRY_URL =
44
+ 'https://raw.githubusercontent.com/BahulamAI/awesome-bahulam-plugins/main/registry.json';
45
+
43
46
  function parseArgs(argv) {
44
47
  const parsed = {
45
48
  source: null,
@@ -152,10 +155,13 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
152
155
 
153
156
  Install a pack. For pi sources, pulls the ingredient and scaffolds a
154
157
  full Bahulam pack (composition + state layer + workspace + agent), then
155
- installs it. For git/tarball/local sources, installs the existing pack.
158
+ installs it. For registry/git/tarball/local sources, installs the
159
+ existing pack.
156
160
 
157
161
  Sources:
158
162
  pi:<npm-package>[@<version>] Scaffold + install from a pi package
163
+ <registry-name> Install from awesome-bahulam-plugins
164
+ bahulam:<registry-name> Explicit awesome-bahulam-plugins lookup
159
165
  <git-url>[.git] Clone a hand-authored pack
160
166
  <tarball-url> Download + install a pack tarball
161
167
  <local-path> Copy + install a local pack directory
@@ -166,7 +172,7 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
166
172
  --no-state Skip the persistent state layer (pi sources only)
167
173
  --no-workspace Skip the reactive workspace panel (pi sources only)
168
174
  --project Install into ./.bahulam/plugins/ instead of ~/.bahulam/plugins/
169
- --ref <ref> Git branch/tag/commit (git sources only)
175
+ --ref <ref> Git branch/tag/commit (git or registry sources)
170
176
  --json Machine-readable output
171
177
 
172
178
  `);
@@ -186,6 +192,12 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
186
192
  throw new Error(`unrecognized source: ${args.source}`);
187
193
  }
188
194
 
195
+ const registryName = registryNameFromSource(args.source, classified);
196
+ if (registryName) {
197
+ await installFromBahulamRegistry({ name: registryName, targetDir, cwd, args });
198
+ return;
199
+ }
200
+
189
201
  // Non-pi paths reuse the plugin-manage install machinery.
190
202
  let dest;
191
203
  if (classified.kind === 'git') {
@@ -194,8 +206,6 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
194
206
  dest = await installFromTarball({ url: classified.url, targetDir, force: args.force });
195
207
  } else if (classified.kind === 'local') {
196
208
  dest = await installFromLocal({ src: classified.path, targetDir, force: args.force });
197
- } else if (classified.kind === 'name') {
198
- throw new Error(`registry lookup for bare names is not yet wired into \`bahulam install\`. Provide a git URL, tarball URL, local path, or pi: source.`);
199
209
  } else {
200
210
  throw new Error(`could not resolve source: ${args.source}`);
201
211
  }
@@ -207,6 +217,118 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
207
217
  }
208
218
  }
209
219
 
220
+ export function registryNameFromSource(source, classified = null) {
221
+ const raw = String(source || '').trim();
222
+ const prefix = 'bahulam:';
223
+ if (raw.toLowerCase().startsWith(prefix)) {
224
+ const name = raw.slice(prefix.length).trim();
225
+ if (!name) {
226
+ throw new Error(`bahulam registry source requires a plugin name, e.g. ${CYAN}bahulam:manim-studio${RESET}`);
227
+ }
228
+ return name;
229
+ }
230
+ if (classified?.kind === 'name') return classified.name;
231
+ return null;
232
+ }
233
+
234
+ async function installFromBahulamRegistry({ name, targetDir, cwd, args }) {
235
+ const entry = await resolveBahulamRegistryPlugin(name, { cwd });
236
+ const repository = entry.repository || entry.repo || entry.url;
237
+ if (!repository) {
238
+ throw new Error(`registry entry "${entry.name}" is missing a repository URL`);
239
+ }
240
+ const ref = args.ref || entry.ref || null;
241
+ const subdir = entry.subdir || entry.path || null;
242
+
243
+ process.stderr.write(
244
+ `${DIM}registry${RESET} ${entry.name} → ${repository}` +
245
+ `${subdir ? `#${subdir}` : ''}${ref ? ` @ ${ref}` : ''}\n`,
246
+ );
247
+
248
+ const dest = await installFromGit({
249
+ url: repository,
250
+ targetDir,
251
+ name: entry.name,
252
+ ref,
253
+ subdir,
254
+ force: args.force,
255
+ });
256
+ await preflightAndReport({ dest, args, cwd });
257
+ }
258
+
259
+ export async function resolveBahulamRegistryPlugin(name, { cwd = process.cwd(), registry = null } = {}) {
260
+ const doc = registry || await loadBahulamPluginRegistry({ cwd });
261
+ const entries = Array.isArray(doc) ? doc : Array.isArray(doc?.plugins) ? doc.plugins : [];
262
+ if (!entries.length) {
263
+ throw new Error('Bahulam plugin registry did not contain any plugins');
264
+ }
265
+
266
+ const needle = normalizeRegistryName(name);
267
+ const entry = entries.find(item => {
268
+ const names = [
269
+ item?.name,
270
+ item?.slug,
271
+ item?.id,
272
+ ...(Array.isArray(item?.aliases) ? item.aliases : []),
273
+ ];
274
+ return names.some(candidate => normalizeRegistryName(candidate) === needle);
275
+ });
276
+
277
+ if (!entry) {
278
+ throw new Error(
279
+ `plugin not found in Bahulam registry: ${name}\n` +
280
+ `Available: ${entries.map(item => item.name).filter(Boolean).join(', ') || '(none)'}`,
281
+ );
282
+ }
283
+ return entry;
284
+ }
285
+
286
+ async function loadBahulamPluginRegistry({ cwd = process.cwd() } = {}) {
287
+ const explicit =
288
+ process.env.BAHULAM_PLUGIN_REGISTRY ||
289
+ process.env.BAHULAM_PLUGIN_REGISTRY_PATH ||
290
+ process.env.BAHULAM_PLUGIN_REGISTRY_URL;
291
+ if (explicit) return readRegistryLocation(explicit, cwd);
292
+
293
+ try {
294
+ return await readRegistryLocation(DEFAULT_BAHULAM_PLUGIN_REGISTRY_URL, cwd);
295
+ } catch (err) {
296
+ const localPath = findLocalRegistry(cwd);
297
+ if (localPath) return readRegistryJson(localPath);
298
+ throw new Error(`failed to load Bahulam plugin registry: ${err.message}`);
299
+ }
300
+ }
301
+
302
+ async function readRegistryLocation(location, cwd) {
303
+ if (/^https?:\/\//i.test(location)) {
304
+ const res = await fetch(location, { headers: { accept: 'application/json' } });
305
+ if (!res.ok) throw new Error(`${location} returned HTTP ${res.status}`);
306
+ return res.json();
307
+ }
308
+ const filePath = path.isAbsolute(location) ? location : path.resolve(cwd, location);
309
+ return readRegistryJson(filePath);
310
+ }
311
+
312
+ function readRegistryJson(filePath) {
313
+ try {
314
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
315
+ } catch (err) {
316
+ throw new Error(`failed to read registry ${filePath}: ${err.message}`);
317
+ }
318
+ }
319
+
320
+ function findLocalRegistry(cwd) {
321
+ const candidates = [
322
+ path.resolve(cwd, '../awesome-bahulam-plugins/registry.json'),
323
+ path.resolve(cwd, 'awesome-bahulam-plugins/registry.json'),
324
+ ];
325
+ return candidates.find(candidate => fs.existsSync(candidate)) || null;
326
+ }
327
+
328
+ function normalizeRegistryName(name) {
329
+ return String(name || '').trim().replace(/^bahulam:/i, '').toLowerCase();
330
+ }
331
+
210
332
  async function installPiWithScaffolding({ classified, targetDir, cwd, args }) {
211
333
  const { bahulamHome } = await import('../core/paths.mjs');
212
334
  const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
@@ -231,6 +353,12 @@ async function installPiWithScaffolding({ classified, targetDir, cwd, args }) {
231
353
  // Step 2: ensure the tools cache is present.
232
354
  const discovered = await discoverPiTools(piDir, { pluginName: classified.package_name });
233
355
 
356
+ // Step 2b: host check for required binaries. Install (unlike pull)
357
+ // implies "use this now", so a missing ffmpeg-class dep will fail at
358
+ // first tool call — better to fail loudly here. `--force` bypasses
359
+ // for offline provisioning / CI where deps land later.
360
+ await enforceHostRequirements({ piDir, packageName: classified.package_name, args });
361
+
234
362
  // Step 3: generate the pack directory (composes + state + agent + panel).
235
363
  process.stderr.write(`${DIM}scaffolding pack…${RESET}\n`);
236
364
  const { dest, slug, namespace, exposeTools, agentSlug } = scaffoldPiPack({
@@ -267,6 +395,28 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
267
395
  // Do it in one command; the scaffolder path already has the pi ingredient.
268
396
  await resolveComposeDependencies(m, { targetDir: path.dirname(dest) });
269
397
 
398
+ // Host check for each composed pi ingredient (same policy as the
399
+ // scaffolder path). Blocks install if a required binary is missing.
400
+ const composes = m.spec?.composes || [];
401
+ if (composes.length && !meta) {
402
+ // meta present == scaffolder path already did this pre-scaffold
403
+ const { bahulamHome } = await import('../core/paths.mjs');
404
+ const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
405
+ for (const compose of composes) {
406
+ if (!compose.package_name) continue;
407
+ const safeName = compose.package_name.replace(/[/@]/g, '_');
408
+ const piDir = path.join(piBaseDir, safeName);
409
+ if (!fs.existsSync(piDir)) continue;
410
+ try {
411
+ await enforceHostRequirements({ piDir, packageName: compose.package_name, args });
412
+ } catch (err) {
413
+ // Roll back the pack install — the composed dep won't work.
414
+ fs.rmSync(dest, { recursive: true, force: true });
415
+ throw err;
416
+ }
417
+ }
418
+ }
419
+
270
420
  if (args.json) {
271
421
  process.stdout.write(JSON.stringify({
272
422
  ok: true,
@@ -293,3 +443,62 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
293
443
  }
294
444
  process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
295
445
  }
446
+
447
+ /**
448
+ * Install-time host check: read the ingredient's requirements sidecar,
449
+ * verify each detected binary is on PATH. Throws with an actionable
450
+ * message (per-OS install hints) if anything required is missing.
451
+ * `--force` bypasses (for CI, offline provisioning, dev workflows where
452
+ * deps land later).
453
+ *
454
+ * Env vars / credentials are warn-only — many pi tools have optional
455
+ * features and blocking on a missing PEXELS_API_KEY when the user only
456
+ * wants media_probe is too aggressive.
457
+ */
458
+ async function enforceHostRequirements({ piDir, packageName, args }) {
459
+ const { checkRequirementsAgainstHost, REQUIREMENTS_FILE, analyzeRequirements } =
460
+ await import('../plugins/pi-compat/requirements.mjs');
461
+
462
+ const sidecar = path.join(piDir, REQUIREMENTS_FILE);
463
+ let reqs = null;
464
+ if (fs.existsSync(sidecar)) {
465
+ try { reqs = JSON.parse(fs.readFileSync(sidecar, 'utf-8')); } catch { /* re-analyze */ }
466
+ }
467
+ if (!reqs) {
468
+ // Sidecar was missing (older ingredient install or analyzer crash) —
469
+ // synthesize on the fly so the check is never silently skipped.
470
+ let discoveredTools = null;
471
+ const toolsCache = path.join(piDir, '.bahulam-tools.json');
472
+ if (fs.existsSync(toolsCache)) {
473
+ try { discoveredTools = JSON.parse(fs.readFileSync(toolsCache, 'utf-8')); } catch { /* ignore */ }
474
+ }
475
+ reqs = analyzeRequirements(piDir, { discoveredTools });
476
+ }
477
+ if (!reqs?.system_binaries?.length) {
478
+ // Nothing to check.
479
+ return;
480
+ }
481
+
482
+ const host = checkRequirementsAgainstHost(reqs);
483
+ const missing = host.binaries.filter(b => !b.found);
484
+ if (missing.length === 0) return;
485
+
486
+ const platformKey = process.platform === 'darwin' ? 'darwin' : 'linux';
487
+ const lines = [];
488
+ lines.push(`${packageName} needs ${missing.length} system binar${missing.length === 1 ? 'y' : 'ies'} not found on your PATH:`);
489
+ for (const b of missing) {
490
+ const hint = b.install_hints?.[platformKey];
491
+ lines.push(` · ${b.name}${hint ? ` — install: ${CYAN}${hint}${RESET}` : ''}`);
492
+ }
493
+ if (args.force) {
494
+ process.stderr.write(`${YELLOW}!${RESET} ${lines.join('\n')}\n`);
495
+ process.stderr.write(`${YELLOW}!${RESET} ${DIM}--force set — continuing anyway. Composed tools using these binaries will fail at first call.${RESET}\n`);
496
+ return;
497
+ }
498
+ const err = new Error(
499
+ `${lines.join('\n')}\n\n` +
500
+ `Install the missing binaries, then rerun. Or use ${CYAN}--force${RESET} to install without them (composed tools using these will fail at first call).\n` +
501
+ `Verify anytime with: ${CYAN}bahulam plugin doctor pi:${packageName}${RESET}`,
502
+ );
503
+ throw err;
504
+ }
@@ -206,6 +206,7 @@ export async function installFromGit({ url, targetDir, name, ref, subdir, force
206
206
  await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
207
207
  }
208
208
  writeStamp(dest, { origin: { kind: 'git', url, ref: ref || null, subdir: subdir || null } });
209
+ await installPackNpmDeps(dest, name);
209
210
  return dest;
210
211
  }
211
212
 
@@ -226,10 +227,53 @@ export async function installFromTarball({ url, targetDir, name, force }) {
226
227
  else await run('tar', ['-xzf', tmp, '-C', dest, '--strip-components=1']);
227
228
  fs.unlinkSync(tmp);
228
229
  writeStamp(dest, { origin: { kind: 'tarball', url } });
230
+ await installPackNpmDeps(dest, guessed);
229
231
  return dest;
230
232
  }
231
233
 
232
- export async function installFromPi({ packageName, versionRange, force }) {
234
+ // Materialize a hand-authored pack's node_modules/ after clone/copy/extract.
235
+ // Shared by installFromGit / installFromTarball / installFromLocal so a pack
236
+ // with `package.json::dependencies` doesn't need `cd ~/.bahulam/plugins/x && npm install`.
237
+ async function installPackNpmDeps(packDir, displayName = null) {
238
+ const pkgPath = path.join(packDir, 'package.json');
239
+ if (fs.existsSync(pkgPath)) {
240
+ const { installPackageDependencies } = await import('../plugins/npm-install.mjs');
241
+ await installPackageDependencies({
242
+ dir: packDir,
243
+ packageName: displayName || path.basename(packDir),
244
+ kind: 'pack',
245
+ forceScripts: false, // hand-authored packs default to --ignore-scripts
246
+ });
247
+ }
248
+ // Requirements preflight for the pack itself. Walks the pack's own
249
+ // source (tools/*.mjs etc.) to surface system binaries the pack shells
250
+ // out to and env vars it reads. Same analyzer that pi ingredients use.
251
+ await surfacePackRequirements(packDir, displayName);
252
+ }
253
+
254
+ async function surfacePackRequirements(packDir, displayName = null) {
255
+ try {
256
+ const { analyzeRequirements, formatRequirementsReport } =
257
+ await import('../plugins/pi-compat/requirements.mjs');
258
+ const reqs = analyzeRequirements(packDir);
259
+ if (!reqs || (!reqs.system_binaries.length && !reqs.env_vars.length && !reqs.readme_sections.length && !reqs.skills_available.length)) {
260
+ return; // Nothing worth telling the user about.
261
+ }
262
+ const lines = formatRequirementsReport(reqs, { verbose: false });
263
+ for (const l of lines) {
264
+ const icon = l.level === 'warn' ? `${YELLOW}!${RESET}` : l.level === 'ok' ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
265
+ process.stderr.write(` ${icon} ${l.text}\n`);
266
+ }
267
+ if (reqs.system_binaries?.length) {
268
+ const name = displayName || path.basename(packDir);
269
+ process.stderr.write(` ${DIM}run${RESET} ${CYAN}bahulam plugin doctor ${name}${RESET} ${DIM}to check your environment${RESET}\n`);
270
+ }
271
+ } catch (err) {
272
+ if (process.env.DEBUG) process.stderr.write(` ${DIM}requirements analyzer skipped: ${err.message}${RESET}\n`);
273
+ }
274
+ }
275
+
276
+ export async function installFromPi({ packageName, versionRange, force, forceScripts = false }) {
233
277
  // Pi packages live in ~/.bahulam/plugins-pi/ (or $BAHULAM_HOME/plugins-pi/)
234
278
  // so `bahulam plugin list` doesn't confuse them with our own packs. The
235
279
  // tool executor reads from the same canonical path via bahulamHome().
@@ -255,31 +299,17 @@ export async function installFromPi({ packageName, versionRange, force }) {
255
299
  if (!tarballs.length) throw new Error(`npm pack produced no tarball for ${spec}`);
256
300
  await run('tar', ['-xzf', path.join(tmp, tarballs[0]), '-C', dest, '--strip-components=1']);
257
301
 
258
- // Pi packages typically declare their runtime deps under
259
- // `peerDependencies` (assuming pi will provide them). We're the host
260
- // now, so materialize those. Two steps because npm arborist crashes
261
- // when peerDependencies use `*` versions during install:
262
- // 1. Rewrite package.json to move peers into dependencies (resolved
263
- // version), and drop the peers block so the resolver stops
264
- // reconciling.
265
- // 2. `npm install` — the dependencies section is normal for npm.
266
- const pkgPath = path.join(dest, 'package.json');
267
- let pkgJson = {};
268
- try { pkgJson = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { /* ignore */ }
269
- const merged = { ...(pkgJson.dependencies || {}) };
270
- for (const [name, range] of Object.entries(pkgJson.peerDependencies || {})) {
271
- if (!merged[name]) merged[name] = range === '*' ? 'latest' : range;
272
- }
273
- if (Object.keys(merged).length) {
274
- const rewritten = { ...pkgJson, dependencies: merged };
275
- delete rewritten.peerDependencies;
276
- fs.writeFileSync(pkgPath, JSON.stringify(rewritten, null, 2));
277
- process.stderr.write(` ${DIM}installing ${Object.keys(merged).length} pi runtime deps…${RESET}\n`);
278
- await run('npm', ['install', '--no-audit', '--no-fund', '--legacy-peer-deps', '--silent'], {
279
- cwd: dest,
280
- stdio: ['ignore', 'pipe', 'pipe'],
281
- });
282
- }
302
+ // Materialize node_modules for the ingredient. Handles pi's peer-dep
303
+ // quirk and gates postinstall scripts on the verified-package list
304
+ // (§13.6.1c of PRD-102) so unreviewed pi packages can't run arbitrary
305
+ // code at pull time.
306
+ const { installPackageDependencies } = await import('../plugins/npm-install.mjs');
307
+ await installPackageDependencies({
308
+ dir: dest,
309
+ packageName,
310
+ kind: 'pi',
311
+ forceScripts,
312
+ });
283
313
 
284
314
  // Read the resolved version so the stamp captures what we actually got.
285
315
  let resolvedVersion = null;
@@ -303,15 +333,36 @@ export async function installFromPi({ packageName, versionRange, force }) {
303
333
  // `cat` and lets executor invocations skip the first-run probe cost.
304
334
  // Best-effort: failure here is non-fatal (returns tools:[] until next
305
335
  // invocation retries) so a broken extension can still be diagnosed.
336
+ let discoveredShape = null;
306
337
  try {
307
338
  const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
308
- const shape = await discoverPiTools(dest, { pluginName: packageName, force: true });
309
- process.stderr.write(` ${DIM}discovered${RESET} ${(shape.tools || []).length} tool(s), ${(shape.commands || []).length} command(s)\n`);
339
+ discoveredShape = await discoverPiTools(dest, { pluginName: packageName, force: true });
340
+ process.stderr.write(` ${DIM}discovered${RESET} ${(discoveredShape.tools || []).length} tool(s), ${(discoveredShape.commands || []).length} command(s)\n`);
310
341
  } catch (probeErr) {
311
342
  process.stderr.write(` ${YELLOW}!${RESET} Tool discovery failed: ${probeErr.message}\n`);
312
343
  process.stderr.write(` ${DIM}The package installed but no tools were probed. Re-probe with:${RESET}\n`);
313
344
  process.stderr.write(` ${DIM} node -e "import('${path.resolve('src/plugins/pi-compat/probe.mjs')}').then(m => m.discoverPiTools('${dest}', { pluginName: '${packageName}', force: true }))"${RESET}\n`);
314
345
  }
346
+
347
+ // Requirements preflight (§13.6.1i). Never blocks — this is a heads-up
348
+ // before the user commits to composing the ingredient. Findings land
349
+ // in <dest>/.bahulam-requirements.json for the scaffolder + doctor.
350
+ try {
351
+ const { analyzeRequirements, formatRequirementsReport } = await import('../plugins/pi-compat/requirements.mjs');
352
+ const reqs = analyzeRequirements(dest, { discoveredTools: discoveredShape });
353
+ const lines = formatRequirementsReport(reqs, { verbose: false });
354
+ for (const l of lines) {
355
+ const icon = l.level === 'warn' ? `${YELLOW}!${RESET}` : l.level === 'ok' ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
356
+ process.stderr.write(` ${icon} ${l.text}\n`);
357
+ }
358
+ const missingBin = (reqs.system_binaries || []).length;
359
+ if (missingBin > 0) {
360
+ process.stderr.write(` ${DIM}run${RESET} ${CYAN}bahulam plugin doctor ${packageName}${RESET} ${DIM}to check your environment${RESET}\n`);
361
+ }
362
+ } catch (reqErr) {
363
+ // Non-fatal — analyzer is a nice-to-have.
364
+ if (process.env.DEBUG) process.stderr.write(` ${DIM}requirements analyzer skipped: ${reqErr.message}${RESET}\n`);
365
+ }
315
366
  } catch (err) {
316
367
  rmrf(dest);
317
368
  throw new Error(`pi install failed for ${spec}: ${err.message}`);
@@ -330,8 +381,11 @@ export async function installFromLocal({ src, targetDir, force }) {
330
381
  rmrf(dest);
331
382
  }
332
383
  fs.mkdirSync(targetDir, { recursive: true });
333
- fs.cpSync(src, dest, { recursive: true });
384
+ // Copy the pack but skip node_modules — we'll materialize fresh below
385
+ // (source's node_modules can be stale, platform-specific, or bloat).
386
+ fs.cpSync(src, dest, { recursive: true, filter: (s) => path.basename(s) !== 'node_modules' });
334
387
  writeStamp(dest, { origin: { kind: 'local', path: src } });
388
+ await installPackNpmDeps(dest, name);
335
389
  return dest;
336
390
  }
337
391
 
@@ -621,6 +675,137 @@ async function cmdValidate(args, cwd) {
621
675
  if (!result.ok) process.exit(1);
622
676
  }
623
677
 
678
+ // ── doctor ─────────────────────────────────────────────────────────
679
+ //
680
+ // Check that an installed pack's composed ingredients (pi:) have their
681
+ // required system binaries and env vars present on the host. Exits non-
682
+ // zero on missing required items (script-friendly for CI setup checks).
683
+
684
+ async function cmdDoctor(args, cwd) {
685
+ const target = args.pluginName;
686
+ const { bahulamHome } = await import('../core/paths.mjs');
687
+ const { analyzeRequirements, formatRequirementsReport, checkRequirementsAgainstHost, REQUIREMENTS_FILE } =
688
+ await import('../plugins/pi-compat/requirements.mjs');
689
+ const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
690
+
691
+ // Which ingredient dirs to check?
692
+ // - `bahulam plugin doctor <pack-slug>` → all pi: composes referenced by that pack
693
+ // - `bahulam plugin doctor pi:<name>` → that specific pi ingredient
694
+ // - `bahulam plugin doctor` (no arg) → every pi ingredient installed
695
+ let ingredientDirs = [];
696
+ if (!target) {
697
+ if (fs.existsSync(piBaseDir)) {
698
+ for (const entry of fs.readdirSync(piBaseDir, { withFileTypes: true })) {
699
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
700
+ ingredientDirs.push({ label: entry.name, dir: path.join(piBaseDir, entry.name) });
701
+ }
702
+ }
703
+ }
704
+ } else if (target.startsWith('pi:')) {
705
+ const packageName = target.slice(3);
706
+ const safe = packageName.replace(/[/@]/g, '_');
707
+ const dir = path.join(piBaseDir, safe);
708
+ if (!fs.existsSync(dir)) throw new Error(`pi ingredient not installed: ${packageName}`);
709
+ ingredientDirs.push({ label: packageName, dir });
710
+ } else {
711
+ const found = findByName(target, cwd);
712
+ if (!found) throw new Error(`plugin not found: ${target}`);
713
+ const scan = readManifest(found.directory);
714
+ const composes = scan?.manifest?.spec?.composes || [];
715
+ for (const c of composes) {
716
+ if (!c.package_name) continue;
717
+ const safe = c.package_name.replace(/[/@]/g, '_');
718
+ const dir = path.join(piBaseDir, safe);
719
+ if (!fs.existsSync(dir)) {
720
+ process.stderr.write(` ${YELLOW}!${RESET} pi ingredient ${c.package_name} referenced by ${found.name} but not installed\n`);
721
+ continue;
722
+ }
723
+ ingredientDirs.push({ label: `${found.name} ← pi:${c.package_name}`, dir });
724
+ }
725
+ // Also check the pack's OWN source — hand-authored packs may shell
726
+ // out to system binaries (manim, docker, ffmpeg) or read env vars
727
+ // regardless of whether they compose any pi ingredients.
728
+ ingredientDirs.push({ label: found.name, dir: found.directory, isPack: true });
729
+ }
730
+
731
+ const report = [];
732
+ let missingRequired = 0;
733
+
734
+ for (const { label, dir } of ingredientDirs) {
735
+ // Load or (re)compute the requirements sidecar.
736
+ let reqs = null;
737
+ const sidecar = path.join(dir, REQUIREMENTS_FILE);
738
+ if (fs.existsSync(sidecar)) {
739
+ try { reqs = JSON.parse(fs.readFileSync(sidecar, 'utf-8')); } catch { /* re-analyze */ }
740
+ }
741
+ if (!reqs) {
742
+ try {
743
+ let tools = null;
744
+ const toolsPath = path.join(dir, '.bahulam-tools.json');
745
+ if (fs.existsSync(toolsPath)) tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'));
746
+ reqs = analyzeRequirements(dir, { discoveredTools: tools });
747
+ } catch (err) {
748
+ report.push({ label, dir, error: err.message });
749
+ continue;
750
+ }
751
+ }
752
+ const host = checkRequirementsAgainstHost(reqs);
753
+ const missing = host.binaries.filter(b => !b.found).length;
754
+ missingRequired += missing;
755
+ report.push({ label, dir, reqs, host, missing });
756
+ }
757
+
758
+ if (args.json) {
759
+ process.stdout.write(JSON.stringify({
760
+ ok: missingRequired === 0,
761
+ missing_binaries: missingRequired,
762
+ checked: report,
763
+ }, null, 2) + '\n');
764
+ if (missingRequired > 0) process.exit(1);
765
+ return;
766
+ }
767
+
768
+ if (report.length === 0) {
769
+ process.stderr.write(`${DIM}No pi ingredients found to check.${RESET}\n`);
770
+ return;
771
+ }
772
+
773
+ for (const r of report) {
774
+ process.stderr.write(`\n${BOLD}${CYAN}${r.label}${RESET} ${DIM}${r.dir}${RESET}\n`);
775
+ if (r.error) { process.stderr.write(` ${RED}✗${RESET} ${r.error}\n`); continue; }
776
+ const lines = formatRequirementsReport(r.reqs, { verbose: false });
777
+ for (const l of lines) {
778
+ const icon = l.level === 'warn' ? `${YELLOW}!${RESET}` : l.level === 'ok' ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
779
+ process.stderr.write(` ${icon} ${l.text}\n`);
780
+ }
781
+ if (r.host.binaries.length) {
782
+ process.stderr.write(` ${DIM}binaries:${RESET}\n`);
783
+ for (const b of r.host.binaries) {
784
+ if (b.found) {
785
+ process.stderr.write(` ${GREEN}✓${RESET} ${b.name}${b.version ? ` ${DIM}${b.version}${RESET}` : ''}${b.path ? ` ${DIM}${b.path}${RESET}` : ''}\n`);
786
+ } else {
787
+ const hint = b.install_hints?.[process.platform === 'darwin' ? 'darwin' : 'linux'];
788
+ process.stderr.write(` ${RED}✗${RESET} ${b.name}${hint ? ` ${DIM}install:${RESET} ${CYAN}${hint}${RESET}` : ''}\n`);
789
+ }
790
+ }
791
+ }
792
+ if (r.host.env_vars.length) {
793
+ process.stderr.write(` ${DIM}env vars:${RESET}\n`);
794
+ for (const v of r.host.env_vars) {
795
+ const status = v.set ? `${GREEN}✓${RESET}` : (v.credential ? `${RED}✗${RESET}` : `${YELLOW}⚠${RESET}`);
796
+ process.stderr.write(` ${status} ${v.name}${v.credential ? ` ${DIM}(credential)${RESET}` : ''}\n`);
797
+ }
798
+ }
799
+ }
800
+ process.stderr.write('\n');
801
+ if (missingRequired > 0) {
802
+ process.stderr.write(`${RED}✗${RESET} ${missingRequired} required binar${missingRequired === 1 ? 'y' : 'ies'} missing.\n\n`);
803
+ process.exit(1);
804
+ } else {
805
+ process.stderr.write(`${GREEN}✓${RESET} all detected requirements satisfied.\n\n`);
806
+ }
807
+ }
808
+
624
809
  export async function handlePluginManagementCommand(args, { cwd = process.cwd(), throwOnError = false } = {}) {
625
810
  try {
626
811
  switch (args.action) {
@@ -630,6 +815,7 @@ export async function handlePluginManagementCommand(args, { cwd = process.cwd(),
630
815
  case 'enable': toggle(args, cwd, true); return;
631
816
  case 'disable': toggle(args, cwd, false); return;
632
817
  case 'info': cmdInfo(args, cwd); return;
818
+ case 'doctor': await cmdDoctor(args, cwd); return;
633
819
  case 'update': case 'upgrade': await cmdUpdate(args, cwd); return;
634
820
  default: throw new Error(`unknown plugin action: ${args.action}`);
635
821
  }
@@ -33,9 +33,6 @@
33
33
  {"value": "qwen/qwq-32b", "label": "QwQ 32B", "provider": "qwen", "inputCost": 0.2, "outputCost": 0.2, "context": 131072, "maxOutput": 32768, "supportsTools": true, "supportsReasoning": true, "harnessValidated": false},
34
34
  {"value": "minimax/minimax-m3", "label": "MiniMax M3", "provider": "minimax", "inputCost": 0.5, "outputCost": 2.0, "context": 1048576, "maxOutput": 512000, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
35
35
  {"value": "minimax/minimax-m2.1", "label": "MiniMax M2.1", "provider": "minimax", "inputCost": 0.3, "outputCost": 1.1, "context": 204800, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
36
- {"value": "xiaomi/mimo-v2.5-pro", "label": "MiMo V2.5 Pro", "provider": "xiaomi", "inputCost": 0.5, "outputCost": 2.0, "context": 1050000, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
37
- {"value": "xiaomi/mimo-v2.5", "label": "MiMo V2.5", "provider": "xiaomi", "inputCost": 0.3, "outputCost": 1.0, "context": 1050000, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"Xiaomi\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
38
- {"value": "xiaomi/mimo-v2-flash", "label": "MiMo V2 Flash", "provider": "xiaomi", "inputCost": 0.1, "outputCost": 0.3, "context": 131072, "maxOutput": 16384, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
39
36
  {"value": "moonshotai/kimi-k2.5", "label": "Kimi K2.5", "provider": "kimi", "inputCost": 0.44, "outputCost": 2.0, "context": 262144, "maxOutput": 262144, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
40
37
  {"value": "moonshotai/kimi-k2", "label": "Kimi K2", "provider": "kimi", "inputCost": 0.44, "outputCost": 2.0, "context": 131072, "maxOutput": 100352, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
41
38
  {"value": "moonshotai/kimi-k2-instruct", "label": "Kimi K2 Instruct", "provider": "kimi", "inputCost": 0.44, "outputCost": 2.0, "context": 131072, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
@@ -60,7 +57,6 @@
60
57
  {"value": "stealth/ox-alpha", "label": "Stealth OX Alpha", "provider": "stealth", "inputCost": 0.3, "outputCost": 1.0, "context": 1048576, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"Stealth\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
61
58
  {"value": "upstage/solar-pro4", "label": "Solar Pro 4", "provider": "upstage", "inputCost": 0.3, "outputCost": 1.0, "context": 524288, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"Upstage\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
62
59
  {"value": "qwen/qwen3-coder:free", "label": "Qwen3 Coder (Free)", "provider": "qwen", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
63
- {"value": "xiaomi/mimo-v2-flash:free", "label": "MiMo V2 Flash (Free)", "provider": "xiaomi", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 16384, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
64
60
  {"value": "meta-llama/llama-3.3-70b-instruct:free", "label": "Llama 3.3 70B (Free)", "provider": "meta", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 8192, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
65
61
  {"value": "qwen/qwen3-8b:free", "label": "Qwen3 8B (Free)", "provider": "qwen", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 8192, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
66
62
  {"value": "nvidia/nemotron-3-ultra-550b-a55b:free", "label": "Nemotron 3 Ultra 550B (Free)", "provider": "nvidia", "inputCost": 0, "outputCost": 0, "context": 1000000, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"NVIDIA\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
@@ -30,7 +30,6 @@ const MODEL_CONTEXT_WINDOWS = {
30
30
  'openai/gpt-5': 400000,
31
31
  'openai/gpt-5-mini': 400000,
32
32
  'google/gemini-2.5-pro': 1000000,
33
- 'xiaomi/mimo-v2.5': 128000,
34
33
  };
35
34
  const DEFAULT_CONTEXT_WINDOW = 128000;
36
35
 
@@ -171,6 +171,7 @@ export class BahulamStreamClient {
171
171
  this._pauseWaiters = new Set();
172
172
  this._abort = null;
173
173
  this._toolAbort = null;
174
+ this._sawComplete = false;
174
175
 
175
176
  // Transport mode:
176
177
  // 'remote' → cloud backend runs the agent loop server-side.
@@ -352,6 +353,7 @@ export class BahulamStreamClient {
352
353
  this._cancelled = false;
353
354
  this.currentTaskId = null;
354
355
  this._firstEventTimedOut = false;
356
+ this._sawComplete = false;
355
357
 
356
358
  // Bundled mode: spawn the local Python runtime on first turn.
357
359
  // After this, this.baseUrl points at http://127.0.0.1:<random-port>
@@ -500,6 +502,21 @@ export class BahulamStreamClient {
500
502
  if (this._cancelled) {
501
503
  return;
502
504
  }
505
+ if (this._sawComplete) {
506
+ telemetry.track('stream.post_complete_error_ignored', {
507
+ task_id: this.currentTaskId || null,
508
+ last_event_id: this.lastEventId || null,
509
+ message: err?.message || 'stream closed after complete',
510
+ error_code: err?.cause?.code || err?.code || '',
511
+ });
512
+ transportDebug('execute.post_complete_error_ignored', {
513
+ task_id: this.currentTaskId || null,
514
+ last_event_id: this.lastEventId || null,
515
+ error: err?.message || 'stream closed after complete',
516
+ code: err?.cause?.code || err?.code || '',
517
+ });
518
+ return;
519
+ }
503
520
  if (this._firstEventTimedOut) {
504
521
  yield {
505
522
  type: EVENT_TYPES.ERROR,
@@ -579,6 +596,7 @@ export class BahulamStreamClient {
579
596
  }
580
597
 
581
598
  if (event === EVENT_TYPES.COMPLETE) {
599
+ this._sawComplete = true;
582
600
  this._persistMemoryFactsFromComplete(data);
583
601
  }
584
602