@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.
- package/package.json +1 -1
- package/src/auth/tarang-auth.mjs +313 -0
- package/src/commands/agent.mjs +7 -7
- package/src/commands/install.mjs +295 -0
- package/src/commands/plugin-manage.mjs +280 -88
- package/src/config/cli-args.mjs +16 -0
- package/src/config/settings-loader.mjs +15 -0
- package/src/core/background-tasks.mjs +186 -0
- package/src/core/headless.mjs +54 -3
- package/src/core/local-agent.mjs +10 -1
- package/src/core/risk-tier.mjs +1 -0
- package/src/core/stream-client.mjs +95 -15
- package/src/core/tool-executor.mjs +266 -15
- package/src/local-service/agent-relay.mjs +1 -1
- package/src/local-service/server.mjs +116 -14
- package/src/orchestration/approval.mjs +30 -0
- package/src/orchestration/completion-triggers.mjs +40 -0
- package/src/orchestration/dispatch.mjs +118 -0
- package/src/orchestration/events.mjs +19 -0
- package/src/orchestration/graph.mjs +126 -0
- package/src/orchestration/node-runner.mjs +193 -0
- package/src/orchestration/runner.mjs +200 -0
- package/src/plugins/executor.mjs +2 -2
- package/src/plugins/manifest.mjs +30 -27
- package/src/plugins/pi-compat/loader-hook.mjs +45 -0
- package/src/plugins/pi-compat/probe.mjs +294 -0
- package/src/plugins/pi-compat/scaffold.mjs +487 -0
- package/src/plugins/pi-compat/shim.mjs +134 -0
- package/src/plugins/pi-compose.mjs +147 -0
- package/src/plugins/preflight.mjs +35 -10
- package/src/plugins/registry.mjs +6 -0
- package/src/terminal/agents.mjs +8 -3
- package/src/terminal/main.mjs +39 -7
- package/src/terminal/paste-input.mjs +23 -0
- package/src/terminal/repl-render.mjs +65 -10
- package/src/terminal/repl-state.mjs +4 -2
- package/src/terminal/repl.mjs +624 -103
- package/src/tools/agent.mjs +6 -2
- package/src/tools/registry.mjs +107 -4
- package/src/ui/input-dock.mjs +5 -2
- package/src/ui/slash-commands.mjs +1 -1
- 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
|
|
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
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
421
|
+
process.stderr.write(`Install one: ${CYAN}bahulam install <git-url|local-path|pi:name>${RESET}\n`);
|
|
265
422
|
return;
|
|
266
423
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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;
|
package/src/config/cli-args.mjs
CHANGED
|
@@ -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
|
]) {
|