@bahulam/code 0.1.12 → 0.1.14

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.
@@ -23,6 +23,7 @@ import * as path from 'node:path';
23
23
  import { spawn } from 'node:child_process';
24
24
  import { parsePluginManifestFile } from '../plugins/manifest.mjs';
25
25
  import { preflightPlugin, existingInstalledNames } from '../plugins/preflight.mjs';
26
+ import { parsePiSource } from '../plugins/pi-compose.mjs';
26
27
 
27
28
  const RESET = '\x1b[0m';
28
29
  const BOLD = '\x1b[1m';
@@ -32,7 +33,6 @@ const GREEN = '\x1b[32m';
32
33
  const YELLOW = '\x1b[33m';
33
34
  const RED = '\x1b[31m';
34
35
 
35
- const REGISTRY_URL = 'https://raw.githubusercontent.com/BahulamAI/awesome-bahulam-plugins/main/registry.json';
36
36
  const INSTALL_STAMP = '.bahulam-plugin.json';
37
37
 
38
38
  function searchDirs(cwd) {
@@ -42,7 +42,7 @@ function searchDirs(cwd) {
42
42
  ];
43
43
  }
44
44
 
45
- function pluginTargetDir({ global, cwd }) {
45
+ export function pluginTargetDir({ global, cwd }) {
46
46
  return global
47
47
  ? path.join(os.homedir(), '.bahulam', 'plugins')
48
48
  : path.join(cwd, '.bahulam', 'plugins');
@@ -62,10 +62,18 @@ function scanInstalled(cwd) {
62
62
  if (!fs.existsSync(dir)) continue;
63
63
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
64
64
  if (!entry.isDirectory()) continue;
65
+ // Skip dotfile dirs (.bahulam, .git, etc.) that occasionally sit
66
+ // alongside plugin dirs — they aren't plugins and clutter the list.
67
+ if (entry.name.startsWith('.')) continue;
65
68
  const pluginDir = path.join(dir, entry.name);
66
69
  const disabled = entry.name.endsWith('.disabled');
67
70
  const parsed = disabled ? null : readManifest(pluginDir);
71
+ // Require a plugin.yaml/plugin.json to count as a plugin. Anything
72
+ // else in the plugins/ dir is stray (readManifest returns null).
73
+ if (!parsed && !disabled) continue;
68
74
  const stamp = readStamp(pluginDir);
75
+ const agentSlugs = (parsed?.manifest?.spec?.agents || [])
76
+ .map(a => a.slug || a.name).filter(Boolean);
69
77
  found.push({
70
78
  scope,
71
79
  directory: pluginDir,
@@ -76,6 +84,8 @@ function scanInstalled(cwd) {
76
84
  tools: parsed?.manifest?.spec?.tools?.length || 0,
77
85
  agents: parsed?.manifest?.spec?.agents?.length || 0,
78
86
  views: parsed?.manifest?.spec?.workspace?.views?.length || 0,
87
+ composes: parsed?.manifest?.spec?.composes?.length || 0,
88
+ agentSlugs,
79
89
  disabled,
80
90
  origin: stamp?.origin || null,
81
91
  installed_at: stamp?.installed_at || null,
@@ -85,6 +95,53 @@ function scanInstalled(cwd) {
85
95
  return found;
86
96
  }
87
97
 
98
+ // Read the effective agent allowlist from settings for the current cwd
99
+ // (project settings override user-global). Empty array = nothing
100
+ // allowlisted; plugin agents stay workspace-scoped per PRD-102 §6.2.1.
101
+ async function readAgentAllowlist(cwd) {
102
+ try {
103
+ const { loadBahulamSettings } = await import('../config/settings-loader.mjs');
104
+ const { settings } = loadBahulamSettings({ cwd });
105
+ const list = settings?.plugins?.agent_allowlist;
106
+ return Array.isArray(list) ? list.map(String) : [];
107
+ } catch { return []; }
108
+ }
109
+
110
+ // pi ingredients scan: ~/.bahulam/plugins-pi/<name>/. Each entry is an
111
+ // npm package (has package.json) and may have a probe cache.
112
+ function scanPiIngredients() {
113
+ const found = [];
114
+ try {
115
+ const { bahulamHome } = require('../core/paths.mjs');
116
+ // require in ESM won't work synchronously — inline the path calc
117
+ } catch { /* fall through */ }
118
+ const home = process.env.BAHULAM_HOME || path.join(os.homedir(), '.bahulam');
119
+ const piDir = path.join(home, 'plugins-pi');
120
+ if (!fs.existsSync(piDir)) return found;
121
+ for (const entry of fs.readdirSync(piDir, { withFileTypes: true })) {
122
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
123
+ const dir = path.join(piDir, entry.name);
124
+ let version = null, description = '', toolCount = 0;
125
+ try {
126
+ const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf-8'));
127
+ version = pkg.version || null;
128
+ description = pkg.description || '';
129
+ } catch { /* not a valid pi package */ continue; }
130
+ try {
131
+ const cache = JSON.parse(fs.readFileSync(path.join(dir, '.bahulam-tools.json'), 'utf-8'));
132
+ toolCount = Array.isArray(cache.tools) ? cache.tools.length : 0;
133
+ } catch { /* no probe cache yet */ }
134
+ found.push({
135
+ name: entry.name,
136
+ version,
137
+ description,
138
+ directory: dir,
139
+ toolCount,
140
+ });
141
+ }
142
+ return found;
143
+ }
144
+
88
145
  function findByName(name, cwd) {
89
146
  const needle = String(name || '').trim().toLowerCase();
90
147
  return scanInstalled(cwd).find(p =>
@@ -116,8 +173,11 @@ function rmrf(dir) { fs.rmSync(dir, { recursive: true, force: true }); }
116
173
 
117
174
  // ── install ─────────────────────────────────────────────────────────
118
175
 
119
- function classifySource(source) {
176
+ export function classifySource(source) {
120
177
  if (!source) return { kind: 'invalid' };
178
+ const pi = parsePiSource(source);
179
+ if (pi) return pi;
180
+ if (String(source).trim().startsWith('pi:')) return { kind: 'invalid', reason: 'invalid pi source' };
121
181
  if (/^(git@|https?:\/\/).*(\.git|github\.com|gitlab\.com|bitbucket\.org)/i.test(source)) return { kind: 'git', url: source };
122
182
  if (/^https?:\/\/.+\.(tar\.gz|tgz|zip)(\?.*)?$/i.test(source)) return { kind: 'tarball', url: source };
123
183
  const abs = path.isAbsolute(source) ? source : path.resolve(process.cwd(), source);
@@ -125,17 +185,7 @@ function classifySource(source) {
125
185
  return { kind: 'name', name: source };
126
186
  }
127
187
 
128
- async function fetchRegistryEntry(name) {
129
- try {
130
- const res = await fetch(REGISTRY_URL);
131
- if (!res.ok) return null;
132
- const registry = await res.json();
133
- const list = Array.isArray(registry) ? registry : (registry.plugins || []);
134
- return list.find(entry => (entry.name || '').toLowerCase() === name.toLowerCase()) || null;
135
- } catch { return null; }
136
- }
137
-
138
- async function installFromGit({ url, targetDir, name, ref, subdir, force }) {
188
+ export async function installFromGit({ url, targetDir, name, ref, subdir, force }) {
139
189
  const dirName = name || url.replace(/\.git$/, '').split('/').filter(Boolean).pop();
140
190
  const dest = path.join(targetDir, dirName);
141
191
  if (fs.existsSync(dest)) {
@@ -156,10 +206,11 @@ async function installFromGit({ url, targetDir, name, ref, subdir, force }) {
156
206
  await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
157
207
  }
158
208
  writeStamp(dest, { origin: { kind: 'git', url, ref: ref || null, subdir: subdir || null } });
209
+ await installPackNpmDeps(dest, name);
159
210
  return dest;
160
211
  }
161
212
 
162
- async function installFromTarball({ url, targetDir, name, force }) {
213
+ export async function installFromTarball({ url, targetDir, name, force }) {
163
214
  const guessed = name || path.basename(url).replace(/\.(tar\.gz|tgz|zip)(\?.*)?$/i, '');
164
215
  const dest = path.join(targetDir, guessed);
165
216
  if (fs.existsSync(dest)) {
@@ -176,10 +227,152 @@ async function installFromTarball({ url, targetDir, name, force }) {
176
227
  else await run('tar', ['-xzf', tmp, '-C', dest, '--strip-components=1']);
177
228
  fs.unlinkSync(tmp);
178
229
  writeStamp(dest, { origin: { kind: 'tarball', url } });
230
+ await installPackNpmDeps(dest, guessed);
179
231
  return dest;
180
232
  }
181
233
 
182
- async function installFromLocal({ src, targetDir, 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 }) {
277
+ // Pi packages live in ~/.bahulam/plugins-pi/ (or $BAHULAM_HOME/plugins-pi/)
278
+ // so `bahulam plugin list` doesn't confuse them with our own packs. The
279
+ // tool executor reads from the same canonical path via bahulamHome().
280
+ const { bahulamHome } = await import('../core/paths.mjs');
281
+ const piDir = path.join(bahulamHome(), 'plugins-pi');
282
+ const safeName = packageName.replace(/[/@]/g, '_');
283
+ const dest = path.join(piDir, safeName);
284
+ if (fs.existsSync(dest)) {
285
+ if (!force) throw new Error(`already installed: ${dest} (use --force to overwrite)`);
286
+ rmrf(dest);
287
+ }
288
+ fs.mkdirSync(dest, { recursive: true });
289
+
290
+ // Use `npm pack` to fetch the tarball without polluting a global npm
291
+ // install. Extract into `<piDir>/<safeName>/package/` following npm's
292
+ // tarball layout, then flatten one level so the plugin root has
293
+ // package.json at top.
294
+ const spec = versionRange ? `${packageName}@${versionRange}` : packageName;
295
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bahulam-pi-'));
296
+ try {
297
+ await run('npm', ['pack', spec, '--pack-destination', tmp, '--silent'], { stdio: ['ignore', 'pipe', 'pipe'] });
298
+ const tarballs = fs.readdirSync(tmp).filter(f => f.endsWith('.tgz'));
299
+ if (!tarballs.length) throw new Error(`npm pack produced no tarball for ${spec}`);
300
+ await run('tar', ['-xzf', path.join(tmp, tarballs[0]), '-C', dest, '--strip-components=1']);
301
+
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
+ });
313
+
314
+ // Read the resolved version so the stamp captures what we actually got.
315
+ let resolvedVersion = null;
316
+ try {
317
+ const pkg = JSON.parse(fs.readFileSync(path.join(dest, 'package.json'), 'utf-8'));
318
+ resolvedVersion = pkg.version || null;
319
+ } catch { /* leave null */ }
320
+
321
+ writeStamp(dest, {
322
+ origin: {
323
+ kind: 'pi',
324
+ spec: `pi:${spec}`,
325
+ package_name: packageName,
326
+ version_range: versionRange || null,
327
+ resolved_version: resolvedVersion,
328
+ },
329
+ });
330
+
331
+ // Probe the extension once so `<dest>/.bahulam-tools.json` exists
332
+ // right after install — lets the user inspect discovered tools with
333
+ // `cat` and lets executor invocations skip the first-run probe cost.
334
+ // Best-effort: failure here is non-fatal (returns tools:[] until next
335
+ // invocation retries) so a broken extension can still be diagnosed.
336
+ let discoveredShape = null;
337
+ try {
338
+ const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
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`);
341
+ } catch (probeErr) {
342
+ process.stderr.write(` ${YELLOW}!${RESET} Tool discovery failed: ${probeErr.message}\n`);
343
+ process.stderr.write(` ${DIM}The package installed but no tools were probed. Re-probe with:${RESET}\n`);
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`);
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
+ }
366
+ } catch (err) {
367
+ rmrf(dest);
368
+ throw new Error(`pi install failed for ${spec}: ${err.message}`);
369
+ } finally {
370
+ rmrf(tmp);
371
+ }
372
+ return dest;
373
+ }
374
+
375
+ export async function installFromLocal({ src, targetDir, force }) {
183
376
  const manifestScan = readManifest(src);
184
377
  const name = manifestScan?.manifest?.metadata?.name || path.basename(src);
185
378
  const dest = path.join(targetDir, name);
@@ -188,95 +381,149 @@ async function installFromLocal({ src, targetDir, force }) {
188
381
  rmrf(dest);
189
382
  }
190
383
  fs.mkdirSync(targetDir, { recursive: true });
191
- 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' });
192
387
  writeStamp(dest, { origin: { kind: 'local', path: src } });
388
+ await installPackNpmDeps(dest, name);
193
389
  return dest;
194
390
  }
195
391
 
196
- async function cmdInstall(args, cwd) {
197
- const source = args.source;
198
- if (!source) throw new Error('install requires a source (git URL, tarball URL, local path, or registry name)');
199
- const targetDir = pluginTargetDir({ global: args.global, cwd });
200
- const classified = classifySource(source);
201
-
202
- let dest;
203
- if (classified.kind === 'git') {
204
- dest = await installFromGit({ url: classified.url, targetDir, ref: args.ref, force: args.force });
205
- } else if (classified.kind === 'tarball') {
206
- dest = await installFromTarball({ url: classified.url, targetDir, force: args.force });
207
- } else if (classified.kind === 'local') {
208
- dest = await installFromLocal({ src: classified.path, targetDir, force: args.force });
209
- } else if (classified.kind === 'name') {
210
- const entry = await fetchRegistryEntry(classified.name);
211
- if (!entry) throw new Error(`no registry entry for "${classified.name}". Provide a git URL or local path instead.`);
212
- if (entry.repository) {
213
- dest = await installFromGit({ url: entry.repository, targetDir, name: entry.name, ref: args.ref || entry.ref, subdir: entry.subdir || null, force: args.force });
214
- } else if (entry.tarball) {
215
- dest = await installFromTarball({ url: entry.tarball, targetDir, name: entry.name, force: args.force });
216
- } else {
217
- throw new Error(`registry entry "${classified.name}" has no repository or tarball URL`);
392
+ /**
393
+ * Auto-install pi packages referenced by a pack's spec.composes:. Callers
394
+ * invoke this after preflight so a hand-authored pack that composes
395
+ * missing pi ingredients still resolves in one command.
396
+ */
397
+ export async function resolveComposeDependencies(manifest, { targetDir } = {}) {
398
+ const composes = manifest?.spec?.composes || [];
399
+ if (!composes.length) return;
400
+ const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
401
+ const { bahulamHome } = await import('../core/paths.mjs');
402
+ const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
403
+ for (const compose of composes) {
404
+ if (!compose.package_name) continue;
405
+ const safeName = compose.package_name.replace(/[/@]/g, '_');
406
+ const piDest = path.join(piBaseDir, safeName);
407
+ if (!fs.existsSync(piDest)) {
408
+ process.stderr.write(` ${DIM}composing${RESET} ${compose.source} → installing\n`);
409
+ try {
410
+ await installFromPi({
411
+ packageName: compose.package_name,
412
+ versionRange: compose.version_range,
413
+ targetDir,
414
+ force: false,
415
+ });
416
+ } catch (err) {
417
+ process.stderr.write(` ${YELLOW}!${RESET} Failed to install ${compose.source}: ${err.message}\n`);
418
+ continue;
419
+ }
420
+ }
421
+ try {
422
+ await discoverPiTools(piDest, { pluginName: compose.package_name });
423
+ } catch (err) {
424
+ process.stderr.write(` ${YELLOW}!${RESET} Probe failed for ${compose.source}: ${err.message}\n`);
218
425
  }
219
- } else {
220
- throw new Error(`could not resolve source: ${source}`);
221
- }
222
-
223
- // Hard preflight: schema, tool names, handlers import, view files exist,
224
- // agent tool refs resolve, no shadow of built-ins, no collisions.
225
- // Any error rolls back the install so we never leave a broken plugin on disk.
226
- const preflight = await preflightPlugin(dest, {
227
- existingPluginNames: () => existingInstalledNames(cwd),
228
- });
229
- if (!preflight.ok) {
230
- rmrf(dest);
231
- const detail = preflight.errors.map(e => ` · ${e}`).join('\n');
232
- throw new Error(`preflight failed — rolled back ${dest}:\n${detail}`);
233
- }
234
- if (preflight.warnings.length) {
235
- for (const w of preflight.warnings) process.stderr.write(`${YELLOW}!${RESET} ${w}\n`);
236
- }
237
- const m = preflight.manifest;
238
- const stamp = readStamp(dest);
239
- if (stamp) writeStamp(dest, stamp);
240
-
241
- if (args.json) {
242
- process.stdout.write(JSON.stringify({ ok: true, name: m.metadata.name, version: m.metadata.version, directory: dest }, null, 2) + '\n');
243
- return;
244
426
  }
245
- process.stderr.write(`\n${GREEN}✓${RESET} Installed ${BOLD}${m.metadata.name}${RESET} v${m.metadata.version}\n`);
246
- process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
247
- process.stderr.write(` ${DIM}tools${RESET} ${(m.spec.tools || []).map(t => t.name).join(', ') || '(none)'}\n`);
248
- process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
249
- const views = m.spec.workspace?.views || [];
250
- process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
251
- process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
252
427
  }
253
428
 
254
429
  // ── list ────────────────────────────────────────────────────────────
255
430
 
256
- function cmdList(args, cwd) {
431
+ async function cmdList(args, cwd) {
257
432
  const plugins = scanInstalled(cwd);
433
+ const pi = scanPiIngredients();
434
+ const allowlist = new Set(await readAgentAllowlist(cwd));
435
+ // A pack is "enabled" for this session when at least one of its
436
+ // agents is in plugins.agent_allowlist (PRD-102 §6.2.1). Packs with
437
+ // no agents (tools-only packs) always count as enabled — the
438
+ // allowlist gate only exists for agents.
439
+ const enabled = (p) => p.agentSlugs.length === 0 || p.agentSlugs.some(s => allowlist.has(s));
440
+ const composerFor = (piName) => plugins.filter(p =>
441
+ (p.composes > 0) && Boolean(p) // composes is a count; details need re-read
442
+ );
443
+ // For pi ingredient "used by" hints we re-read manifests once — cheap.
444
+ const pluginComposes = new Map(); // pluginName → [piPackageName, …]
445
+ for (const p of plugins) {
446
+ if (!p.composes) continue;
447
+ try {
448
+ const m = readManifest(p.directory);
449
+ const composes = m?.manifest?.spec?.composes || [];
450
+ pluginComposes.set(p.name, composes.map(c => c.package_name || c.packageName).filter(Boolean));
451
+ } catch { /* skip */ }
452
+ }
453
+ const usedBy = (piName) => [...pluginComposes.entries()]
454
+ .filter(([, refs]) => refs.includes(piName))
455
+ .map(([name]) => name);
456
+
258
457
  if (args.json) {
259
- process.stdout.write(JSON.stringify({ ok: true, plugins }, null, 2) + '\n');
458
+ const withEnable = plugins.map(p => ({
459
+ ...p,
460
+ enabled: enabled(p),
461
+ allowlisted_agents: p.agentSlugs.filter(s => allowlist.has(s)),
462
+ }));
463
+ process.stdout.write(JSON.stringify({
464
+ ok: true,
465
+ plugins: withEnable,
466
+ pi_ingredients: pi.map(x => ({ ...x, used_by: usedBy(x.name) })),
467
+ agent_allowlist: [...allowlist],
468
+ }, null, 2) + '\n');
260
469
  return;
261
470
  }
262
- if (!plugins.length) {
471
+
472
+ // ── Bahulam packs ──
473
+ if (!plugins.length && !pi.length) {
263
474
  process.stderr.write(`${DIM}No plugins installed.${RESET}\n`);
264
- process.stderr.write(`Install one: ${CYAN}bahulam plugin install <git-url|local-path|name>${RESET}\n`);
475
+ process.stderr.write(`Install one: ${CYAN}bahulam install <git-url|local-path|pi:name>${RESET}\n`);
265
476
  return;
266
477
  }
267
- const nameW = Math.max(8, ...plugins.map(p => p.name.length));
268
- const verW = Math.max(7, ...plugins.map(p => (p.version || '—').length));
269
- const scopeW = 7;
270
- const header = `${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} ${'SCOPE'.padEnd(scopeW)} STATUS SURFACE${RESET}\n`;
271
- process.stderr.write(header);
272
- for (const p of plugins) {
273
- const status = p.disabled ? `${YELLOW}disabled${RESET}` : `${GREEN}active${RESET} `;
274
- const surface = `${p.tools}t ${p.agents}a ${p.views}v`;
275
- process.stderr.write(
276
- `${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${p.scope.padEnd(scopeW)} ${status} ${DIM}${surface}${RESET}\n`
277
- );
478
+
479
+ if (plugins.length) {
480
+ process.stderr.write(`\n${BOLD}BAHULAM PACKS${RESET} ${DIM}(installed via bahulam install)${RESET}\n`);
481
+ const nameW = Math.max(8, ...plugins.map(p => p.name.length));
482
+ const verW = Math.max(7, ...plugins.map(p => (p.version || '—').length));
483
+ const header = `${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} STATUS ENABLED? SURFACE${RESET}\n`;
484
+ process.stderr.write(header);
485
+ for (const p of plugins) {
486
+ const status = p.disabled ? `${YELLOW}disabled${RESET}` : `${GREEN}active${RESET} `;
487
+ const enableCol = p.disabled ? DIM + '— ' + RESET
488
+ : enabled(p) ? GREEN + 'enabled ' + RESET
489
+ : YELLOW + 'not-enab' + RESET;
490
+ const surface = `${p.tools}t ${p.agents}a ${p.views}v${p.composes ? ` +${p.composes}c` : ''}`;
491
+ process.stderr.write(
492
+ `${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${status} ${enableCol} ${DIM}${surface}${RESET}\n`
493
+ );
494
+ }
495
+ process.stderr.write(`\n${DIM}surface: t=native-tools a=agents v=views c=composed-pi-packages${RESET}\n`);
496
+ const notEnabled = plugins.filter(p => !p.disabled && !enabled(p));
497
+ if (notEnabled.length) {
498
+ process.stderr.write(`\n${YELLOW}!${RESET} ${notEnabled.length} pack${notEnabled.length === 1 ? '' : 's'} installed but NOT enabled in this session.\n`);
499
+ process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted.\n`);
500
+ process.stderr.write(` Add to ${CYAN}.bahulam/settings.json${RESET}:\n`);
501
+ const slugs = notEnabled.flatMap(p => p.agentSlugs);
502
+ process.stderr.write(` ${DIM}{ "plugins": { "agent_allowlist": ${JSON.stringify(slugs)} } }${RESET}\n`);
503
+ }
504
+ }
505
+
506
+ // ── Pi ingredients ──
507
+ if (pi.length) {
508
+ process.stderr.write(`\n${BOLD}PI INGREDIENTS${RESET} ${DIM}(installed via bahulam pull pi:<name> — composable, not directly runnable)${RESET}\n`);
509
+ const nameW = Math.max(8, ...pi.map(p => p.name.length));
510
+ const verW = Math.max(7, ...pi.map(p => (p.version || '—').length));
511
+ process.stderr.write(`${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} TOOLS COMPOSED-BY${RESET}\n`);
512
+ for (const p of pi) {
513
+ const users = usedBy(p.name);
514
+ const composers = users.length ? users.join(', ') : `${DIM}(nothing yet — add to a pack's composes:)${RESET}`;
515
+ process.stderr.write(
516
+ `${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${String(p.toolCount).padStart(3)} ${composers}\n`
517
+ );
518
+ }
519
+ const orphans = pi.filter(p => usedBy(p.name).length === 0);
520
+ if (orphans.length) {
521
+ process.stderr.write(`\n${YELLOW}!${RESET} ${orphans.length} pi ingredient${orphans.length === 1 ? '' : 's'} installed but not composed by any pack.\n`);
522
+ process.stderr.write(` Pi ingredients are unusable on their own — reference in a pack's ${CYAN}spec.composes:${RESET} block.\n`);
523
+ }
278
524
  }
279
- process.stderr.write(`\n${DIM}${plugins.length} plugin${plugins.length === 1 ? '' : 's'} · surface: t=tools a=agents v=views${RESET}\n`);
525
+
526
+ process.stderr.write('\n');
280
527
  }
281
528
 
282
529
  // ── remove ──────────────────────────────────────────────────────────
@@ -428,16 +675,147 @@ async function cmdValidate(args, cwd) {
428
675
  if (!result.ok) process.exit(1);
429
676
  }
430
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
+
431
809
  export async function handlePluginManagementCommand(args, { cwd = process.cwd(), throwOnError = false } = {}) {
432
810
  try {
433
811
  switch (args.action) {
434
- case 'install': await cmdInstall(args, cwd); return;
435
812
  case 'validate': case 'check': case 'lint': await cmdValidate(args, cwd); return;
436
- case 'list': case 'ls': cmdList(args, cwd); return;
813
+ case 'list': case 'ls': await cmdList(args, cwd); return;
437
814
  case 'remove': case 'rm': case 'uninstall': cmdRemove(args, cwd); return;
438
815
  case 'enable': toggle(args, cwd, true); return;
439
816
  case 'disable': toggle(args, cwd, false); return;
440
817
  case 'info': cmdInfo(args, cwd); return;
818
+ case 'doctor': await cmdDoctor(args, cwd); return;
441
819
  case 'update': case 'upgrade': await cmdUpdate(args, cwd); return;
442
820
  default: throw new Error(`unknown plugin action: ${args.action}`);
443
821
  }