@bahulam/code 0.1.12 → 0.1.13

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)) {
@@ -159,7 +209,7 @@ async function installFromGit({ url, targetDir, name, ref, subdir, force }) {
159
209
  return dest;
160
210
  }
161
211
 
162
- async function installFromTarball({ url, targetDir, name, force }) {
212
+ export async function installFromTarball({ url, targetDir, name, force }) {
163
213
  const guessed = name || path.basename(url).replace(/\.(tar\.gz|tgz|zip)(\?.*)?$/i, '');
164
214
  const dest = path.join(targetDir, guessed);
165
215
  if (fs.existsSync(dest)) {
@@ -179,7 +229,99 @@ async function installFromTarball({ url, targetDir, name, force }) {
179
229
  return dest;
180
230
  }
181
231
 
182
- async function installFromLocal({ src, targetDir, force }) {
232
+ export async function installFromPi({ packageName, versionRange, force }) {
233
+ // Pi packages live in ~/.bahulam/plugins-pi/ (or $BAHULAM_HOME/plugins-pi/)
234
+ // so `bahulam plugin list` doesn't confuse them with our own packs. The
235
+ // tool executor reads from the same canonical path via bahulamHome().
236
+ const { bahulamHome } = await import('../core/paths.mjs');
237
+ const piDir = path.join(bahulamHome(), 'plugins-pi');
238
+ const safeName = packageName.replace(/[/@]/g, '_');
239
+ const dest = path.join(piDir, safeName);
240
+ if (fs.existsSync(dest)) {
241
+ if (!force) throw new Error(`already installed: ${dest} (use --force to overwrite)`);
242
+ rmrf(dest);
243
+ }
244
+ fs.mkdirSync(dest, { recursive: true });
245
+
246
+ // Use `npm pack` to fetch the tarball without polluting a global npm
247
+ // install. Extract into `<piDir>/<safeName>/package/` following npm's
248
+ // tarball layout, then flatten one level so the plugin root has
249
+ // package.json at top.
250
+ const spec = versionRange ? `${packageName}@${versionRange}` : packageName;
251
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bahulam-pi-'));
252
+ try {
253
+ await run('npm', ['pack', spec, '--pack-destination', tmp, '--silent'], { stdio: ['ignore', 'pipe', 'pipe'] });
254
+ const tarballs = fs.readdirSync(tmp).filter(f => f.endsWith('.tgz'));
255
+ if (!tarballs.length) throw new Error(`npm pack produced no tarball for ${spec}`);
256
+ await run('tar', ['-xzf', path.join(tmp, tarballs[0]), '-C', dest, '--strip-components=1']);
257
+
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
+ }
283
+
284
+ // Read the resolved version so the stamp captures what we actually got.
285
+ let resolvedVersion = null;
286
+ try {
287
+ const pkg = JSON.parse(fs.readFileSync(path.join(dest, 'package.json'), 'utf-8'));
288
+ resolvedVersion = pkg.version || null;
289
+ } catch { /* leave null */ }
290
+
291
+ writeStamp(dest, {
292
+ origin: {
293
+ kind: 'pi',
294
+ spec: `pi:${spec}`,
295
+ package_name: packageName,
296
+ version_range: versionRange || null,
297
+ resolved_version: resolvedVersion,
298
+ },
299
+ });
300
+
301
+ // Probe the extension once so `<dest>/.bahulam-tools.json` exists
302
+ // right after install — lets the user inspect discovered tools with
303
+ // `cat` and lets executor invocations skip the first-run probe cost.
304
+ // Best-effort: failure here is non-fatal (returns tools:[] until next
305
+ // invocation retries) so a broken extension can still be diagnosed.
306
+ try {
307
+ 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`);
310
+ } catch (probeErr) {
311
+ process.stderr.write(` ${YELLOW}!${RESET} Tool discovery failed: ${probeErr.message}\n`);
312
+ process.stderr.write(` ${DIM}The package installed but no tools were probed. Re-probe with:${RESET}\n`);
313
+ 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
+ }
315
+ } catch (err) {
316
+ rmrf(dest);
317
+ throw new Error(`pi install failed for ${spec}: ${err.message}`);
318
+ } finally {
319
+ rmrf(tmp);
320
+ }
321
+ return dest;
322
+ }
323
+
324
+ export async function installFromLocal({ src, targetDir, force }) {
183
325
  const manifestScan = readManifest(src);
184
326
  const name = manifestScan?.manifest?.metadata?.name || path.basename(src);
185
327
  const dest = path.join(targetDir, name);
@@ -193,90 +335,141 @@ async function installFromLocal({ src, targetDir, force }) {
193
335
  return dest;
194
336
  }
195
337
 
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`);
338
+ /**
339
+ * Auto-install pi packages referenced by a pack's spec.composes:. Callers
340
+ * invoke this after preflight so a hand-authored pack that composes
341
+ * missing pi ingredients still resolves in one command.
342
+ */
343
+ export async function resolveComposeDependencies(manifest, { targetDir } = {}) {
344
+ const composes = manifest?.spec?.composes || [];
345
+ if (!composes.length) return;
346
+ const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
347
+ const { bahulamHome } = await import('../core/paths.mjs');
348
+ const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
349
+ for (const compose of composes) {
350
+ if (!compose.package_name) continue;
351
+ const safeName = compose.package_name.replace(/[/@]/g, '_');
352
+ const piDest = path.join(piBaseDir, safeName);
353
+ if (!fs.existsSync(piDest)) {
354
+ process.stderr.write(` ${DIM}composing${RESET} ${compose.source} → installing\n`);
355
+ try {
356
+ await installFromPi({
357
+ packageName: compose.package_name,
358
+ versionRange: compose.version_range,
359
+ targetDir,
360
+ force: false,
361
+ });
362
+ } catch (err) {
363
+ process.stderr.write(` ${YELLOW}!${RESET} Failed to install ${compose.source}: ${err.message}\n`);
364
+ continue;
365
+ }
366
+ }
367
+ try {
368
+ await discoverPiTools(piDest, { pluginName: compose.package_name });
369
+ } catch (err) {
370
+ process.stderr.write(` ${YELLOW}!${RESET} Probe failed for ${compose.source}: ${err.message}\n`);
218
371
  }
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
372
  }
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
373
  }
253
374
 
254
375
  // ── list ────────────────────────────────────────────────────────────
255
376
 
256
- function cmdList(args, cwd) {
377
+ async function cmdList(args, cwd) {
257
378
  const plugins = scanInstalled(cwd);
379
+ const pi = scanPiIngredients();
380
+ const allowlist = new Set(await readAgentAllowlist(cwd));
381
+ // A pack is "enabled" for this session when at least one of its
382
+ // agents is in plugins.agent_allowlist (PRD-102 §6.2.1). Packs with
383
+ // no agents (tools-only packs) always count as enabled — the
384
+ // allowlist gate only exists for agents.
385
+ const enabled = (p) => p.agentSlugs.length === 0 || p.agentSlugs.some(s => allowlist.has(s));
386
+ const composerFor = (piName) => plugins.filter(p =>
387
+ (p.composes > 0) && Boolean(p) // composes is a count; details need re-read
388
+ );
389
+ // For pi ingredient "used by" hints we re-read manifests once — cheap.
390
+ const pluginComposes = new Map(); // pluginName → [piPackageName, …]
391
+ for (const p of plugins) {
392
+ if (!p.composes) continue;
393
+ try {
394
+ const m = readManifest(p.directory);
395
+ const composes = m?.manifest?.spec?.composes || [];
396
+ pluginComposes.set(p.name, composes.map(c => c.package_name || c.packageName).filter(Boolean));
397
+ } catch { /* skip */ }
398
+ }
399
+ const usedBy = (piName) => [...pluginComposes.entries()]
400
+ .filter(([, refs]) => refs.includes(piName))
401
+ .map(([name]) => name);
402
+
258
403
  if (args.json) {
259
- process.stdout.write(JSON.stringify({ ok: true, plugins }, null, 2) + '\n');
404
+ const withEnable = plugins.map(p => ({
405
+ ...p,
406
+ enabled: enabled(p),
407
+ allowlisted_agents: p.agentSlugs.filter(s => allowlist.has(s)),
408
+ }));
409
+ process.stdout.write(JSON.stringify({
410
+ ok: true,
411
+ plugins: withEnable,
412
+ pi_ingredients: pi.map(x => ({ ...x, used_by: usedBy(x.name) })),
413
+ agent_allowlist: [...allowlist],
414
+ }, null, 2) + '\n');
260
415
  return;
261
416
  }
262
- if (!plugins.length) {
417
+
418
+ // ── Bahulam packs ──
419
+ if (!plugins.length && !pi.length) {
263
420
  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`);
421
+ process.stderr.write(`Install one: ${CYAN}bahulam install <git-url|local-path|pi:name>${RESET}\n`);
265
422
  return;
266
423
  }
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
- );
424
+
425
+ if (plugins.length) {
426
+ process.stderr.write(`\n${BOLD}BAHULAM PACKS${RESET} ${DIM}(installed via bahulam install)${RESET}\n`);
427
+ const nameW = Math.max(8, ...plugins.map(p => p.name.length));
428
+ const verW = Math.max(7, ...plugins.map(p => (p.version || '—').length));
429
+ const header = `${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} STATUS ENABLED? SURFACE${RESET}\n`;
430
+ process.stderr.write(header);
431
+ for (const p of plugins) {
432
+ const status = p.disabled ? `${YELLOW}disabled${RESET}` : `${GREEN}active${RESET} `;
433
+ const enableCol = p.disabled ? DIM + '— ' + RESET
434
+ : enabled(p) ? GREEN + 'enabled ' + RESET
435
+ : YELLOW + 'not-enab' + RESET;
436
+ const surface = `${p.tools}t ${p.agents}a ${p.views}v${p.composes ? ` +${p.composes}c` : ''}`;
437
+ process.stderr.write(
438
+ `${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${status} ${enableCol} ${DIM}${surface}${RESET}\n`
439
+ );
440
+ }
441
+ process.stderr.write(`\n${DIM}surface: t=native-tools a=agents v=views c=composed-pi-packages${RESET}\n`);
442
+ const notEnabled = plugins.filter(p => !p.disabled && !enabled(p));
443
+ if (notEnabled.length) {
444
+ process.stderr.write(`\n${YELLOW}!${RESET} ${notEnabled.length} pack${notEnabled.length === 1 ? '' : 's'} installed but NOT enabled in this session.\n`);
445
+ process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted.\n`);
446
+ process.stderr.write(` Add to ${CYAN}.bahulam/settings.json${RESET}:\n`);
447
+ const slugs = notEnabled.flatMap(p => p.agentSlugs);
448
+ process.stderr.write(` ${DIM}{ "plugins": { "agent_allowlist": ${JSON.stringify(slugs)} } }${RESET}\n`);
449
+ }
278
450
  }
279
- process.stderr.write(`\n${DIM}${plugins.length} plugin${plugins.length === 1 ? '' : 's'} · surface: t=tools a=agents v=views${RESET}\n`);
451
+
452
+ // ── Pi ingredients ──
453
+ if (pi.length) {
454
+ process.stderr.write(`\n${BOLD}PI INGREDIENTS${RESET} ${DIM}(installed via bahulam pull pi:<name> — composable, not directly runnable)${RESET}\n`);
455
+ const nameW = Math.max(8, ...pi.map(p => p.name.length));
456
+ const verW = Math.max(7, ...pi.map(p => (p.version || '—').length));
457
+ process.stderr.write(`${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} TOOLS COMPOSED-BY${RESET}\n`);
458
+ for (const p of pi) {
459
+ const users = usedBy(p.name);
460
+ const composers = users.length ? users.join(', ') : `${DIM}(nothing yet — add to a pack's composes:)${RESET}`;
461
+ process.stderr.write(
462
+ `${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${String(p.toolCount).padStart(3)} ${composers}\n`
463
+ );
464
+ }
465
+ const orphans = pi.filter(p => usedBy(p.name).length === 0);
466
+ if (orphans.length) {
467
+ process.stderr.write(`\n${YELLOW}!${RESET} ${orphans.length} pi ingredient${orphans.length === 1 ? '' : 's'} installed but not composed by any pack.\n`);
468
+ process.stderr.write(` Pi ingredients are unusable on their own — reference in a pack's ${CYAN}spec.composes:${RESET} block.\n`);
469
+ }
470
+ }
471
+
472
+ process.stderr.write('\n');
280
473
  }
281
474
 
282
475
  // ── remove ──────────────────────────────────────────────────────────
@@ -431,9 +624,8 @@ async function cmdValidate(args, cwd) {
431
624
  export async function handlePluginManagementCommand(args, { cwd = process.cwd(), throwOnError = false } = {}) {
432
625
  try {
433
626
  switch (args.action) {
434
- case 'install': await cmdInstall(args, cwd); return;
435
627
  case 'validate': case 'check': case 'lint': await cmdValidate(args, cwd); return;
436
- case 'list': case 'ls': cmdList(args, cwd); return;
628
+ case 'list': case 'ls': await cmdList(args, cwd); return;
437
629
  case 'remove': case 'rm': case 'uninstall': cmdRemove(args, cwd); return;
438
630
  case 'enable': toggle(args, cwd, true); return;
439
631
  case 'disable': toggle(args, cwd, false); return;
@@ -1,4 +1,5 @@
1
1
  import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
2
3
  import * as path from 'node:path';
3
4
  import { deepMerge } from '../core/policy-resolver.mjs';
4
5
 
@@ -26,12 +27,26 @@ function readJson(filePath) {
26
27
  }
27
28
  }
28
29
 
30
+ // Global settings live alongside global plugins (~/.bahulam/), so an
31
+ // allowlist that applies to a globally-installed plugin belongs here.
32
+ // $BAHULAM_HOME overrides the default so tests and one-off installs
33
+ // don't touch the real home directory.
34
+ function globalSettingsPath() {
35
+ const home = process.env.BAHULAM_HOME || path.join(os.homedir(), '.bahulam');
36
+ return path.join(home, 'settings.json');
37
+ }
38
+
29
39
  export function loadBahulamSettings({ cwd = process.cwd() } = {}) {
30
40
  const base = path.join(cwd, '.bahulam');
31
41
  const layers = [
32
42
  { name: 'default', path: null, data: DEFAULT_BAHULAM_SETTINGS },
33
43
  ];
44
+ // Merge order (later overrides earlier): default → global → project → local.
45
+ // Global keeps values that stay constant across projects (plugins live
46
+ // in ~/.bahulam, so plugin allowlists sit here). Project & local remain
47
+ // the last word so a repo can tighten or loosen without touching global.
34
48
  for (const [name, file] of [
49
+ ['global', globalSettingsPath()],
35
50
  ['project', path.join(base, 'settings.json')],
36
51
  ['local', path.join(base, 'settings.local.json')],
37
52
  ]) {
@@ -850,6 +850,53 @@ export function createToolExecutor({
850
850
  const name = String(toolDef.name || '').trim();
851
851
  if (!name || toolMap[name]) continue;
852
852
  const pluginName = toolDef._plugin_name || toolDef.plugin_name || null;
853
+ if (toolDef._composed?.kind === 'pi') {
854
+ // Composed pi tools resolve at invocation time: look up the
855
+ // installed pi package's directory, load the specific handler
856
+ // via the shim-backed probe, invoke, return the result. Handler
857
+ // cache is per-session (per tool name) to amortize the ~50ms
858
+ // child-process overhead on repeat calls.
859
+ let _piInvokeP = null;
860
+ registerPluginTool(name, async (args, options = {}) => {
861
+ try {
862
+ if (!_piInvokeP) {
863
+ const { loadPiToolHandler } = await import('../plugins/pi-compat/probe.mjs');
864
+ const packageName = toolDef._composed.package_name;
865
+ const originalName = toolDef._composed.original_name;
866
+ const { bahulamHome } = await import('./paths.mjs');
867
+ const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
868
+ const piDir = path.join(piBaseDir, packageName.replace(/[/@]/g, '_'));
869
+ if (!fs.existsSync(piDir)) {
870
+ return {
871
+ success: false,
872
+ output: `Composed pi tool '${name}' unavailable: pi package ${packageName} is not installed. Run \`bahulam plugin install pi:${packageName}\`.`,
873
+ _tool: name,
874
+ _plugin: pluginName,
875
+ _composed: toolDef._composed,
876
+ };
877
+ }
878
+ _piInvokeP = loadPiToolHandler(piDir, originalName, { pluginName: packageName });
879
+ }
880
+ const invoke = await _piInvokeP;
881
+ const result = await invoke(args || {});
882
+ return {
883
+ ...(result && typeof result === 'object' ? result : { success: true, output: String(result) }),
884
+ _tool: name,
885
+ _plugin: pluginName,
886
+ _composed: toolDef._composed,
887
+ };
888
+ } catch (err) {
889
+ return {
890
+ success: false,
891
+ output: `Composed pi tool '${name}' failed: ${err.message}`,
892
+ _tool: name,
893
+ _plugin: pluginName,
894
+ _composed: toolDef._composed,
895
+ };
896
+ }
897
+ }, { pluginName, source: 'pi', composed: toolDef._composed });
898
+ continue;
899
+ }
853
900
  registerPluginTool(name, async (args, options = {}) => {
854
901
  const handler = await loadPluginTool(toolDef._plugin_dir, toolDef.tool);
855
902
  if (!handler) {
@@ -7,6 +7,7 @@
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
9
  import { load as yamlLoad } from 'js-yaml';
10
+ import { normalizeComposes } from './pi-compose.mjs';
10
11
 
11
12
  /**
12
13
  * Parse a YAML text string into an object using js-yaml.
@@ -185,6 +186,7 @@ export function normalizeManifest(raw, source = '') {
185
186
  // Inline wins on name collision so authors can override a portable
186
187
  // config for the local plugin without editing mcp.json.
187
188
  const mcpServers = _readMcpServers(spec.mcpServers, source);
189
+ const composes = normalizeComposes(spec.composes);
188
190
 
189
191
  return {
190
192
  apiVersion,
@@ -201,6 +203,7 @@ export function normalizeManifest(raw, source = '') {
201
203
  agents,
202
204
  workspace,
203
205
  mcpServers,
206
+ composes,
204
207
  },
205
208
  source,
206
209
  _dir: source ? path.dirname(source) : '',
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Node ESM loader hook that intercepts `import { pi } from 'pi'` and
3
+ * resolves it to a virtual module which re-exports our shim.
4
+ *
5
+ * Registered from probe.mjs via child_process spawn with:
6
+ * node --import ./loader-hook.mjs -e '<probe script>'
7
+ *
8
+ * The virtual module source is generated at load time so it can inline
9
+ * the shim import URL (avoids brittle relative paths across cwd's).
10
+ */
11
+
12
+ import { pathToFileURL } from 'node:url';
13
+ import { register } from 'node:module';
14
+ import * as path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const SHIM_URL = pathToFileURL(path.join(HERE, 'shim.mjs')).href;
19
+ const VIRTUAL_URL = 'bahulam-pi-shim:v1';
20
+
21
+ // Register ourselves as a loader — this file is `--import`ed, and the
22
+ // register() call installs the resolve/load hooks below into a worker
23
+ // data URL. Simpler than a standalone hooks file.
24
+ register(`data:text/javascript,${encodeURIComponent(`
25
+ export function resolve(specifier, context, nextResolve) {
26
+ if (specifier === 'pi') return { shortCircuit: true, url: '${VIRTUAL_URL}' };
27
+ return nextResolve(specifier, context);
28
+ }
29
+ export function load(url, context, nextLoad) {
30
+ if (url === '${VIRTUAL_URL}') {
31
+ return {
32
+ shortCircuit: true,
33
+ format: 'module',
34
+ source: \`
35
+ import { createPiShim } from ${JSON.stringify(SHIM_URL)};
36
+ const captured = globalThis.__bahulam_pi_captured ||= { tools: [], commands: [] };
37
+ const pluginName = process.env.BAHULAM_PI_PLUGIN || 'pi';
38
+ export const pi = createPiShim({ pluginName, captured });
39
+ export default pi;
40
+ \`,
41
+ };
42
+ }
43
+ return nextLoad(url, context);
44
+ }
45
+ `)}`, import.meta.url);