@bahulam/code 0.1.11 → 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.
Files changed (42) hide show
  1. package/package.json +1 -1
  2. package/src/auth/tarang-auth.mjs +313 -0
  3. package/src/commands/agent.mjs +7 -7
  4. package/src/commands/install.mjs +295 -0
  5. package/src/commands/plugin-manage.mjs +280 -88
  6. package/src/config/cli-args.mjs +16 -0
  7. package/src/config/settings-loader.mjs +15 -0
  8. package/src/core/background-tasks.mjs +186 -0
  9. package/src/core/headless.mjs +54 -3
  10. package/src/core/local-agent.mjs +10 -1
  11. package/src/core/risk-tier.mjs +1 -0
  12. package/src/core/stream-client.mjs +95 -15
  13. package/src/core/tool-executor.mjs +266 -15
  14. package/src/local-service/agent-relay.mjs +1 -1
  15. package/src/local-service/server.mjs +116 -14
  16. package/src/orchestration/approval.mjs +30 -0
  17. package/src/orchestration/completion-triggers.mjs +40 -0
  18. package/src/orchestration/dispatch.mjs +118 -0
  19. package/src/orchestration/events.mjs +19 -0
  20. package/src/orchestration/graph.mjs +126 -0
  21. package/src/orchestration/node-runner.mjs +193 -0
  22. package/src/orchestration/runner.mjs +200 -0
  23. package/src/plugins/executor.mjs +2 -2
  24. package/src/plugins/manifest.mjs +30 -27
  25. package/src/plugins/pi-compat/loader-hook.mjs +45 -0
  26. package/src/plugins/pi-compat/probe.mjs +294 -0
  27. package/src/plugins/pi-compat/scaffold.mjs +487 -0
  28. package/src/plugins/pi-compat/shim.mjs +134 -0
  29. package/src/plugins/pi-compose.mjs +147 -0
  30. package/src/plugins/preflight.mjs +35 -10
  31. package/src/plugins/registry.mjs +6 -0
  32. package/src/terminal/agents.mjs +8 -3
  33. package/src/terminal/main.mjs +39 -7
  34. package/src/terminal/paste-input.mjs +23 -0
  35. package/src/terminal/repl-render.mjs +65 -10
  36. package/src/terminal/repl-state.mjs +4 -2
  37. package/src/terminal/repl.mjs +624 -103
  38. package/src/tools/agent.mjs +6 -2
  39. package/src/tools/registry.mjs +107 -4
  40. package/src/ui/input-dock.mjs +5 -2
  41. package/src/ui/slash-commands.mjs +1 -1
  42. package/src/ui/sub-agent.mjs +14 -8
@@ -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;
@@ -11,6 +11,8 @@
11
11
  * --max-turns Maximum conversation turns
12
12
  * --allowedTools Comma-separated allowed tools
13
13
  * --disallowedTools Comma-separated denied tools
14
+ * --agent <slug> Run a named agent (local deterministic graph)
15
+ * --workflow <name> Run a named workflow (local deterministic graph)
14
16
  * --verbose, -v Verbose output
15
17
  * --debug, -d Debug mode
16
18
  * --version Show version
@@ -34,6 +36,8 @@ export function parseArgs(args) {
34
36
  resumeSessionId: null,
35
37
  headless: false,
36
38
  skipPermissions: false,
39
+ agent: null,
40
+ workflow: null,
37
41
  vision: [],
38
42
  verbose: false,
39
43
  debug: false,
@@ -109,6 +113,14 @@ export function parseArgs(args) {
109
113
  result.skipPermissions = true; // headless implies skip permissions
110
114
  break;
111
115
 
116
+ case '--agent':
117
+ result.agent = args[++i];
118
+ break;
119
+
120
+ case '--workflow':
121
+ result.workflow = args[++i];
122
+ break;
123
+
112
124
  case '--cache-report':
113
125
  // PRD-071 §1.5 — write a machine-readable cache summary to
114
126
  // <path> at end of run. Consumed by benchmark/cache-check.sh.
@@ -184,6 +196,8 @@ Options:
184
196
  --disallowedTools <tools> Comma-separated list of denied tools
185
197
  --resume, -r [sessionId] Resume last session (or specific session)
186
198
  --continue Alias for --resume
199
+ --agent <slug> Run a named agent as a deterministic local graph
200
+ --workflow <name> Run a named workflow as a deterministic local graph
187
201
  --headless Non-interactive mode: auto-approve, JSONL output
188
202
  --cache-report <file> Write prompt-cache summary JSON to <file> (headless only)
189
203
  --vision <image-path> Attach image path in headless mode
@@ -197,6 +211,8 @@ Examples:
197
211
  occ Start interactive REPL
198
212
  occ -p "What is 2+2?" Run prompt and exit
199
213
  occ -m claude-haiku-4-5 Use Haiku model
214
+ occ --agent explore -p "Map auth flow" Run the explore agent headlessly
215
+ occ --workflow deploy -p "Deploy" Run a workflow headlessly
200
216
  occ --debug -p "Fix bug" Debug mode with prompt
201
217
  `.trim();
202
218
  }
@@ -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
  ]) {