@bahulam/code 0.1.12 → 0.1.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/install.mjs +382 -0
- package/src/commands/plugin-manage.mjs +467 -89
- package/src/config/model-catalog-default.json +0 -4
- package/src/config/settings-loader.mjs +15 -0
- package/src/core/resume-mode.mjs +0 -1
- package/src/core/stream-client.mjs +18 -0
- package/src/core/tool-executor.mjs +47 -0
- package/src/daemon/session-core.mjs +3 -0
- package/src/local-service/agent-relay.mjs +6 -1
- package/src/local-service/file-access.mjs +116 -2
- package/src/local-service/server.mjs +209 -20
- package/src/plugins/manifest.mjs +3 -0
- package/src/plugins/npm-install.mjs +138 -0
- package/src/plugins/pi-compat/loader-hook.mjs +45 -0
- package/src/plugins/pi-compat/probe.mjs +294 -0
- package/src/plugins/pi-compat/requirements.mjs +465 -0
- package/src/plugins/pi-compat/scaffold.mjs +563 -0
- package/src/plugins/pi-compat/shim.mjs +162 -0
- package/src/plugins/pi-compose.mjs +147 -0
- package/src/plugins/preflight.mjs +29 -2
- package/src/plugins/registry.mjs +6 -0
- package/src/terminal/main.mjs +33 -6
- package/src/terminal/paste-input.mjs +23 -0
- package/src/terminal/repl.mjs +104 -30
- package/src/tools/registry.mjs +19 -0
- package/src/ui/input-dock.mjs +53 -12
- package/src/ui/text-layout.mjs +4 -3
|
@@ -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)) {
|
|
@@ -156,10 +206,11 @@ async function installFromGit({ url, targetDir, name, ref, subdir, force }) {
|
|
|
156
206
|
await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
|
|
157
207
|
}
|
|
158
208
|
writeStamp(dest, { origin: { kind: 'git', url, ref: ref || null, subdir: subdir || null } });
|
|
209
|
+
await installPackNpmDeps(dest, name);
|
|
159
210
|
return dest;
|
|
160
211
|
}
|
|
161
212
|
|
|
162
|
-
async function installFromTarball({ url, targetDir, name, force }) {
|
|
213
|
+
export async function installFromTarball({ url, targetDir, name, force }) {
|
|
163
214
|
const guessed = name || path.basename(url).replace(/\.(tar\.gz|tgz|zip)(\?.*)?$/i, '');
|
|
164
215
|
const dest = path.join(targetDir, guessed);
|
|
165
216
|
if (fs.existsSync(dest)) {
|
|
@@ -176,10 +227,152 @@ async function installFromTarball({ url, targetDir, name, force }) {
|
|
|
176
227
|
else await run('tar', ['-xzf', tmp, '-C', dest, '--strip-components=1']);
|
|
177
228
|
fs.unlinkSync(tmp);
|
|
178
229
|
writeStamp(dest, { origin: { kind: 'tarball', url } });
|
|
230
|
+
await installPackNpmDeps(dest, guessed);
|
|
179
231
|
return dest;
|
|
180
232
|
}
|
|
181
233
|
|
|
182
|
-
|
|
234
|
+
// Materialize a hand-authored pack's node_modules/ after clone/copy/extract.
|
|
235
|
+
// Shared by installFromGit / installFromTarball / installFromLocal so a pack
|
|
236
|
+
// with `package.json::dependencies` doesn't need `cd ~/.bahulam/plugins/x && npm install`.
|
|
237
|
+
async function installPackNpmDeps(packDir, displayName = null) {
|
|
238
|
+
const pkgPath = path.join(packDir, 'package.json');
|
|
239
|
+
if (fs.existsSync(pkgPath)) {
|
|
240
|
+
const { installPackageDependencies } = await import('../plugins/npm-install.mjs');
|
|
241
|
+
await installPackageDependencies({
|
|
242
|
+
dir: packDir,
|
|
243
|
+
packageName: displayName || path.basename(packDir),
|
|
244
|
+
kind: 'pack',
|
|
245
|
+
forceScripts: false, // hand-authored packs default to --ignore-scripts
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
// Requirements preflight for the pack itself. Walks the pack's own
|
|
249
|
+
// source (tools/*.mjs etc.) to surface system binaries the pack shells
|
|
250
|
+
// out to and env vars it reads. Same analyzer that pi ingredients use.
|
|
251
|
+
await surfacePackRequirements(packDir, displayName);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function surfacePackRequirements(packDir, displayName = null) {
|
|
255
|
+
try {
|
|
256
|
+
const { analyzeRequirements, formatRequirementsReport } =
|
|
257
|
+
await import('../plugins/pi-compat/requirements.mjs');
|
|
258
|
+
const reqs = analyzeRequirements(packDir);
|
|
259
|
+
if (!reqs || (!reqs.system_binaries.length && !reqs.env_vars.length && !reqs.readme_sections.length && !reqs.skills_available.length)) {
|
|
260
|
+
return; // Nothing worth telling the user about.
|
|
261
|
+
}
|
|
262
|
+
const lines = formatRequirementsReport(reqs, { verbose: false });
|
|
263
|
+
for (const l of lines) {
|
|
264
|
+
const icon = l.level === 'warn' ? `${YELLOW}!${RESET}` : l.level === 'ok' ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
|
|
265
|
+
process.stderr.write(` ${icon} ${l.text}\n`);
|
|
266
|
+
}
|
|
267
|
+
if (reqs.system_binaries?.length) {
|
|
268
|
+
const name = displayName || path.basename(packDir);
|
|
269
|
+
process.stderr.write(` ${DIM}run${RESET} ${CYAN}bahulam plugin doctor ${name}${RESET} ${DIM}to check your environment${RESET}\n`);
|
|
270
|
+
}
|
|
271
|
+
} catch (err) {
|
|
272
|
+
if (process.env.DEBUG) process.stderr.write(` ${DIM}requirements analyzer skipped: ${err.message}${RESET}\n`);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function installFromPi({ packageName, versionRange, force, forceScripts = false }) {
|
|
277
|
+
// Pi packages live in ~/.bahulam/plugins-pi/ (or $BAHULAM_HOME/plugins-pi/)
|
|
278
|
+
// so `bahulam plugin list` doesn't confuse them with our own packs. The
|
|
279
|
+
// tool executor reads from the same canonical path via bahulamHome().
|
|
280
|
+
const { bahulamHome } = await import('../core/paths.mjs');
|
|
281
|
+
const piDir = path.join(bahulamHome(), 'plugins-pi');
|
|
282
|
+
const safeName = packageName.replace(/[/@]/g, '_');
|
|
283
|
+
const dest = path.join(piDir, safeName);
|
|
284
|
+
if (fs.existsSync(dest)) {
|
|
285
|
+
if (!force) throw new Error(`already installed: ${dest} (use --force to overwrite)`);
|
|
286
|
+
rmrf(dest);
|
|
287
|
+
}
|
|
288
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
289
|
+
|
|
290
|
+
// Use `npm pack` to fetch the tarball without polluting a global npm
|
|
291
|
+
// install. Extract into `<piDir>/<safeName>/package/` following npm's
|
|
292
|
+
// tarball layout, then flatten one level so the plugin root has
|
|
293
|
+
// package.json at top.
|
|
294
|
+
const spec = versionRange ? `${packageName}@${versionRange}` : packageName;
|
|
295
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bahulam-pi-'));
|
|
296
|
+
try {
|
|
297
|
+
await run('npm', ['pack', spec, '--pack-destination', tmp, '--silent'], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
298
|
+
const tarballs = fs.readdirSync(tmp).filter(f => f.endsWith('.tgz'));
|
|
299
|
+
if (!tarballs.length) throw new Error(`npm pack produced no tarball for ${spec}`);
|
|
300
|
+
await run('tar', ['-xzf', path.join(tmp, tarballs[0]), '-C', dest, '--strip-components=1']);
|
|
301
|
+
|
|
302
|
+
// Materialize node_modules for the ingredient. Handles pi's peer-dep
|
|
303
|
+
// quirk and gates postinstall scripts on the verified-package list
|
|
304
|
+
// (§13.6.1c of PRD-102) so unreviewed pi packages can't run arbitrary
|
|
305
|
+
// code at pull time.
|
|
306
|
+
const { installPackageDependencies } = await import('../plugins/npm-install.mjs');
|
|
307
|
+
await installPackageDependencies({
|
|
308
|
+
dir: dest,
|
|
309
|
+
packageName,
|
|
310
|
+
kind: 'pi',
|
|
311
|
+
forceScripts,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
// Read the resolved version so the stamp captures what we actually got.
|
|
315
|
+
let resolvedVersion = null;
|
|
316
|
+
try {
|
|
317
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(dest, 'package.json'), 'utf-8'));
|
|
318
|
+
resolvedVersion = pkg.version || null;
|
|
319
|
+
} catch { /* leave null */ }
|
|
320
|
+
|
|
321
|
+
writeStamp(dest, {
|
|
322
|
+
origin: {
|
|
323
|
+
kind: 'pi',
|
|
324
|
+
spec: `pi:${spec}`,
|
|
325
|
+
package_name: packageName,
|
|
326
|
+
version_range: versionRange || null,
|
|
327
|
+
resolved_version: resolvedVersion,
|
|
328
|
+
},
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Probe the extension once so `<dest>/.bahulam-tools.json` exists
|
|
332
|
+
// right after install — lets the user inspect discovered tools with
|
|
333
|
+
// `cat` and lets executor invocations skip the first-run probe cost.
|
|
334
|
+
// Best-effort: failure here is non-fatal (returns tools:[] until next
|
|
335
|
+
// invocation retries) so a broken extension can still be diagnosed.
|
|
336
|
+
let discoveredShape = null;
|
|
337
|
+
try {
|
|
338
|
+
const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
|
|
339
|
+
discoveredShape = await discoverPiTools(dest, { pluginName: packageName, force: true });
|
|
340
|
+
process.stderr.write(` ${DIM}discovered${RESET} ${(discoveredShape.tools || []).length} tool(s), ${(discoveredShape.commands || []).length} command(s)\n`);
|
|
341
|
+
} catch (probeErr) {
|
|
342
|
+
process.stderr.write(` ${YELLOW}!${RESET} Tool discovery failed: ${probeErr.message}\n`);
|
|
343
|
+
process.stderr.write(` ${DIM}The package installed but no tools were probed. Re-probe with:${RESET}\n`);
|
|
344
|
+
process.stderr.write(` ${DIM} node -e "import('${path.resolve('src/plugins/pi-compat/probe.mjs')}').then(m => m.discoverPiTools('${dest}', { pluginName: '${packageName}', force: true }))"${RESET}\n`);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Requirements preflight (§13.6.1i). Never blocks — this is a heads-up
|
|
348
|
+
// before the user commits to composing the ingredient. Findings land
|
|
349
|
+
// in <dest>/.bahulam-requirements.json for the scaffolder + doctor.
|
|
350
|
+
try {
|
|
351
|
+
const { analyzeRequirements, formatRequirementsReport } = await import('../plugins/pi-compat/requirements.mjs');
|
|
352
|
+
const reqs = analyzeRequirements(dest, { discoveredTools: discoveredShape });
|
|
353
|
+
const lines = formatRequirementsReport(reqs, { verbose: false });
|
|
354
|
+
for (const l of lines) {
|
|
355
|
+
const icon = l.level === 'warn' ? `${YELLOW}!${RESET}` : l.level === 'ok' ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
|
|
356
|
+
process.stderr.write(` ${icon} ${l.text}\n`);
|
|
357
|
+
}
|
|
358
|
+
const missingBin = (reqs.system_binaries || []).length;
|
|
359
|
+
if (missingBin > 0) {
|
|
360
|
+
process.stderr.write(` ${DIM}run${RESET} ${CYAN}bahulam plugin doctor ${packageName}${RESET} ${DIM}to check your environment${RESET}\n`);
|
|
361
|
+
}
|
|
362
|
+
} catch (reqErr) {
|
|
363
|
+
// Non-fatal — analyzer is a nice-to-have.
|
|
364
|
+
if (process.env.DEBUG) process.stderr.write(` ${DIM}requirements analyzer skipped: ${reqErr.message}${RESET}\n`);
|
|
365
|
+
}
|
|
366
|
+
} catch (err) {
|
|
367
|
+
rmrf(dest);
|
|
368
|
+
throw new Error(`pi install failed for ${spec}: ${err.message}`);
|
|
369
|
+
} finally {
|
|
370
|
+
rmrf(tmp);
|
|
371
|
+
}
|
|
372
|
+
return dest;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export async function installFromLocal({ src, targetDir, force }) {
|
|
183
376
|
const manifestScan = readManifest(src);
|
|
184
377
|
const name = manifestScan?.manifest?.metadata?.name || path.basename(src);
|
|
185
378
|
const dest = path.join(targetDir, name);
|
|
@@ -188,95 +381,149 @@ async function installFromLocal({ src, targetDir, force }) {
|
|
|
188
381
|
rmrf(dest);
|
|
189
382
|
}
|
|
190
383
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
191
|
-
|
|
384
|
+
// Copy the pack but skip node_modules — we'll materialize fresh below
|
|
385
|
+
// (source's node_modules can be stale, platform-specific, or bloat).
|
|
386
|
+
fs.cpSync(src, dest, { recursive: true, filter: (s) => path.basename(s) !== 'node_modules' });
|
|
192
387
|
writeStamp(dest, { origin: { kind: 'local', path: src } });
|
|
388
|
+
await installPackNpmDeps(dest, name);
|
|
193
389
|
return dest;
|
|
194
390
|
}
|
|
195
391
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const
|
|
211
|
-
if (!
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
392
|
+
/**
|
|
393
|
+
* Auto-install pi packages referenced by a pack's spec.composes:. Callers
|
|
394
|
+
* invoke this after preflight so a hand-authored pack that composes
|
|
395
|
+
* missing pi ingredients still resolves in one command.
|
|
396
|
+
*/
|
|
397
|
+
export async function resolveComposeDependencies(manifest, { targetDir } = {}) {
|
|
398
|
+
const composes = manifest?.spec?.composes || [];
|
|
399
|
+
if (!composes.length) return;
|
|
400
|
+
const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
|
|
401
|
+
const { bahulamHome } = await import('../core/paths.mjs');
|
|
402
|
+
const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
|
|
403
|
+
for (const compose of composes) {
|
|
404
|
+
if (!compose.package_name) continue;
|
|
405
|
+
const safeName = compose.package_name.replace(/[/@]/g, '_');
|
|
406
|
+
const piDest = path.join(piBaseDir, safeName);
|
|
407
|
+
if (!fs.existsSync(piDest)) {
|
|
408
|
+
process.stderr.write(` ${DIM}composing${RESET} ${compose.source} → installing\n`);
|
|
409
|
+
try {
|
|
410
|
+
await installFromPi({
|
|
411
|
+
packageName: compose.package_name,
|
|
412
|
+
versionRange: compose.version_range,
|
|
413
|
+
targetDir,
|
|
414
|
+
force: false,
|
|
415
|
+
});
|
|
416
|
+
} catch (err) {
|
|
417
|
+
process.stderr.write(` ${YELLOW}!${RESET} Failed to install ${compose.source}: ${err.message}\n`);
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
await discoverPiTools(piDest, { pluginName: compose.package_name });
|
|
423
|
+
} catch (err) {
|
|
424
|
+
process.stderr.write(` ${YELLOW}!${RESET} Probe failed for ${compose.source}: ${err.message}\n`);
|
|
218
425
|
}
|
|
219
|
-
} else {
|
|
220
|
-
throw new Error(`could not resolve source: ${source}`);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
// Hard preflight: schema, tool names, handlers import, view files exist,
|
|
224
|
-
// agent tool refs resolve, no shadow of built-ins, no collisions.
|
|
225
|
-
// Any error rolls back the install so we never leave a broken plugin on disk.
|
|
226
|
-
const preflight = await preflightPlugin(dest, {
|
|
227
|
-
existingPluginNames: () => existingInstalledNames(cwd),
|
|
228
|
-
});
|
|
229
|
-
if (!preflight.ok) {
|
|
230
|
-
rmrf(dest);
|
|
231
|
-
const detail = preflight.errors.map(e => ` · ${e}`).join('\n');
|
|
232
|
-
throw new Error(`preflight failed — rolled back ${dest}:\n${detail}`);
|
|
233
|
-
}
|
|
234
|
-
if (preflight.warnings.length) {
|
|
235
|
-
for (const w of preflight.warnings) process.stderr.write(`${YELLOW}!${RESET} ${w}\n`);
|
|
236
|
-
}
|
|
237
|
-
const m = preflight.manifest;
|
|
238
|
-
const stamp = readStamp(dest);
|
|
239
|
-
if (stamp) writeStamp(dest, stamp);
|
|
240
|
-
|
|
241
|
-
if (args.json) {
|
|
242
|
-
process.stdout.write(JSON.stringify({ ok: true, name: m.metadata.name, version: m.metadata.version, directory: dest }, null, 2) + '\n');
|
|
243
|
-
return;
|
|
244
426
|
}
|
|
245
|
-
process.stderr.write(`\n${GREEN}✓${RESET} Installed ${BOLD}${m.metadata.name}${RESET} v${m.metadata.version}\n`);
|
|
246
|
-
process.stderr.write(` ${DIM}location${RESET} ${dest}\n`);
|
|
247
|
-
process.stderr.write(` ${DIM}tools${RESET} ${(m.spec.tools || []).map(t => t.name).join(', ') || '(none)'}\n`);
|
|
248
|
-
process.stderr.write(` ${DIM}agents${RESET} ${(m.spec.agents || []).map(a => a.slug).join(', ') || '(none)'}\n`);
|
|
249
|
-
const views = m.spec.workspace?.views || [];
|
|
250
|
-
process.stderr.write(` ${DIM}views${RESET} ${views.length ? views.map(v => v.name).join(', ') : '(none)'}\n`);
|
|
251
|
-
process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
|
|
252
427
|
}
|
|
253
428
|
|
|
254
429
|
// ── list ────────────────────────────────────────────────────────────
|
|
255
430
|
|
|
256
|
-
function cmdList(args, cwd) {
|
|
431
|
+
async function cmdList(args, cwd) {
|
|
257
432
|
const plugins = scanInstalled(cwd);
|
|
433
|
+
const pi = scanPiIngredients();
|
|
434
|
+
const allowlist = new Set(await readAgentAllowlist(cwd));
|
|
435
|
+
// A pack is "enabled" for this session when at least one of its
|
|
436
|
+
// agents is in plugins.agent_allowlist (PRD-102 §6.2.1). Packs with
|
|
437
|
+
// no agents (tools-only packs) always count as enabled — the
|
|
438
|
+
// allowlist gate only exists for agents.
|
|
439
|
+
const enabled = (p) => p.agentSlugs.length === 0 || p.agentSlugs.some(s => allowlist.has(s));
|
|
440
|
+
const composerFor = (piName) => plugins.filter(p =>
|
|
441
|
+
(p.composes > 0) && Boolean(p) // composes is a count; details need re-read
|
|
442
|
+
);
|
|
443
|
+
// For pi ingredient "used by" hints we re-read manifests once — cheap.
|
|
444
|
+
const pluginComposes = new Map(); // pluginName → [piPackageName, …]
|
|
445
|
+
for (const p of plugins) {
|
|
446
|
+
if (!p.composes) continue;
|
|
447
|
+
try {
|
|
448
|
+
const m = readManifest(p.directory);
|
|
449
|
+
const composes = m?.manifest?.spec?.composes || [];
|
|
450
|
+
pluginComposes.set(p.name, composes.map(c => c.package_name || c.packageName).filter(Boolean));
|
|
451
|
+
} catch { /* skip */ }
|
|
452
|
+
}
|
|
453
|
+
const usedBy = (piName) => [...pluginComposes.entries()]
|
|
454
|
+
.filter(([, refs]) => refs.includes(piName))
|
|
455
|
+
.map(([name]) => name);
|
|
456
|
+
|
|
258
457
|
if (args.json) {
|
|
259
|
-
|
|
458
|
+
const withEnable = plugins.map(p => ({
|
|
459
|
+
...p,
|
|
460
|
+
enabled: enabled(p),
|
|
461
|
+
allowlisted_agents: p.agentSlugs.filter(s => allowlist.has(s)),
|
|
462
|
+
}));
|
|
463
|
+
process.stdout.write(JSON.stringify({
|
|
464
|
+
ok: true,
|
|
465
|
+
plugins: withEnable,
|
|
466
|
+
pi_ingredients: pi.map(x => ({ ...x, used_by: usedBy(x.name) })),
|
|
467
|
+
agent_allowlist: [...allowlist],
|
|
468
|
+
}, null, 2) + '\n');
|
|
260
469
|
return;
|
|
261
470
|
}
|
|
262
|
-
|
|
471
|
+
|
|
472
|
+
// ── Bahulam packs ──
|
|
473
|
+
if (!plugins.length && !pi.length) {
|
|
263
474
|
process.stderr.write(`${DIM}No plugins installed.${RESET}\n`);
|
|
264
|
-
process.stderr.write(`Install one: ${CYAN}bahulam
|
|
475
|
+
process.stderr.write(`Install one: ${CYAN}bahulam install <git-url|local-path|pi:name>${RESET}\n`);
|
|
265
476
|
return;
|
|
266
477
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
478
|
+
|
|
479
|
+
if (plugins.length) {
|
|
480
|
+
process.stderr.write(`\n${BOLD}BAHULAM PACKS${RESET} ${DIM}(installed via bahulam install)${RESET}\n`);
|
|
481
|
+
const nameW = Math.max(8, ...plugins.map(p => p.name.length));
|
|
482
|
+
const verW = Math.max(7, ...plugins.map(p => (p.version || '—').length));
|
|
483
|
+
const header = `${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} STATUS ENABLED? SURFACE${RESET}\n`;
|
|
484
|
+
process.stderr.write(header);
|
|
485
|
+
for (const p of plugins) {
|
|
486
|
+
const status = p.disabled ? `${YELLOW}disabled${RESET}` : `${GREEN}active${RESET} `;
|
|
487
|
+
const enableCol = p.disabled ? DIM + '— ' + RESET
|
|
488
|
+
: enabled(p) ? GREEN + 'enabled ' + RESET
|
|
489
|
+
: YELLOW + 'not-enab' + RESET;
|
|
490
|
+
const surface = `${p.tools}t ${p.agents}a ${p.views}v${p.composes ? ` +${p.composes}c` : ''}`;
|
|
491
|
+
process.stderr.write(
|
|
492
|
+
`${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${status} ${enableCol} ${DIM}${surface}${RESET}\n`
|
|
493
|
+
);
|
|
494
|
+
}
|
|
495
|
+
process.stderr.write(`\n${DIM}surface: t=native-tools a=agents v=views c=composed-pi-packages${RESET}\n`);
|
|
496
|
+
const notEnabled = plugins.filter(p => !p.disabled && !enabled(p));
|
|
497
|
+
if (notEnabled.length) {
|
|
498
|
+
process.stderr.write(`\n${YELLOW}!${RESET} ${notEnabled.length} pack${notEnabled.length === 1 ? '' : 's'} installed but NOT enabled in this session.\n`);
|
|
499
|
+
process.stderr.write(` Their plugin agents won't appear in the model's toolset until allowlisted.\n`);
|
|
500
|
+
process.stderr.write(` Add to ${CYAN}.bahulam/settings.json${RESET}:\n`);
|
|
501
|
+
const slugs = notEnabled.flatMap(p => p.agentSlugs);
|
|
502
|
+
process.stderr.write(` ${DIM}{ "plugins": { "agent_allowlist": ${JSON.stringify(slugs)} } }${RESET}\n`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// ── Pi ingredients ──
|
|
507
|
+
if (pi.length) {
|
|
508
|
+
process.stderr.write(`\n${BOLD}PI INGREDIENTS${RESET} ${DIM}(installed via bahulam pull pi:<name> — composable, not directly runnable)${RESET}\n`);
|
|
509
|
+
const nameW = Math.max(8, ...pi.map(p => p.name.length));
|
|
510
|
+
const verW = Math.max(7, ...pi.map(p => (p.version || '—').length));
|
|
511
|
+
process.stderr.write(`${BOLD}${'NAME'.padEnd(nameW)} ${'VERSION'.padEnd(verW)} TOOLS COMPOSED-BY${RESET}\n`);
|
|
512
|
+
for (const p of pi) {
|
|
513
|
+
const users = usedBy(p.name);
|
|
514
|
+
const composers = users.length ? users.join(', ') : `${DIM}(nothing yet — add to a pack's composes:)${RESET}`;
|
|
515
|
+
process.stderr.write(
|
|
516
|
+
`${p.name.padEnd(nameW)} ${(p.version || '—').padEnd(verW)} ${String(p.toolCount).padStart(3)} ${composers}\n`
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
const orphans = pi.filter(p => usedBy(p.name).length === 0);
|
|
520
|
+
if (orphans.length) {
|
|
521
|
+
process.stderr.write(`\n${YELLOW}!${RESET} ${orphans.length} pi ingredient${orphans.length === 1 ? '' : 's'} installed but not composed by any pack.\n`);
|
|
522
|
+
process.stderr.write(` Pi ingredients are unusable on their own — reference in a pack's ${CYAN}spec.composes:${RESET} block.\n`);
|
|
523
|
+
}
|
|
278
524
|
}
|
|
279
|
-
|
|
525
|
+
|
|
526
|
+
process.stderr.write('\n');
|
|
280
527
|
}
|
|
281
528
|
|
|
282
529
|
// ── remove ──────────────────────────────────────────────────────────
|
|
@@ -428,16 +675,147 @@ async function cmdValidate(args, cwd) {
|
|
|
428
675
|
if (!result.ok) process.exit(1);
|
|
429
676
|
}
|
|
430
677
|
|
|
678
|
+
// ── doctor ─────────────────────────────────────────────────────────
|
|
679
|
+
//
|
|
680
|
+
// Check that an installed pack's composed ingredients (pi:) have their
|
|
681
|
+
// required system binaries and env vars present on the host. Exits non-
|
|
682
|
+
// zero on missing required items (script-friendly for CI setup checks).
|
|
683
|
+
|
|
684
|
+
async function cmdDoctor(args, cwd) {
|
|
685
|
+
const target = args.pluginName;
|
|
686
|
+
const { bahulamHome } = await import('../core/paths.mjs');
|
|
687
|
+
const { analyzeRequirements, formatRequirementsReport, checkRequirementsAgainstHost, REQUIREMENTS_FILE } =
|
|
688
|
+
await import('../plugins/pi-compat/requirements.mjs');
|
|
689
|
+
const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
|
|
690
|
+
|
|
691
|
+
// Which ingredient dirs to check?
|
|
692
|
+
// - `bahulam plugin doctor <pack-slug>` → all pi: composes referenced by that pack
|
|
693
|
+
// - `bahulam plugin doctor pi:<name>` → that specific pi ingredient
|
|
694
|
+
// - `bahulam plugin doctor` (no arg) → every pi ingredient installed
|
|
695
|
+
let ingredientDirs = [];
|
|
696
|
+
if (!target) {
|
|
697
|
+
if (fs.existsSync(piBaseDir)) {
|
|
698
|
+
for (const entry of fs.readdirSync(piBaseDir, { withFileTypes: true })) {
|
|
699
|
+
if (entry.isDirectory() && !entry.name.startsWith('.')) {
|
|
700
|
+
ingredientDirs.push({ label: entry.name, dir: path.join(piBaseDir, entry.name) });
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} else if (target.startsWith('pi:')) {
|
|
705
|
+
const packageName = target.slice(3);
|
|
706
|
+
const safe = packageName.replace(/[/@]/g, '_');
|
|
707
|
+
const dir = path.join(piBaseDir, safe);
|
|
708
|
+
if (!fs.existsSync(dir)) throw new Error(`pi ingredient not installed: ${packageName}`);
|
|
709
|
+
ingredientDirs.push({ label: packageName, dir });
|
|
710
|
+
} else {
|
|
711
|
+
const found = findByName(target, cwd);
|
|
712
|
+
if (!found) throw new Error(`plugin not found: ${target}`);
|
|
713
|
+
const scan = readManifest(found.directory);
|
|
714
|
+
const composes = scan?.manifest?.spec?.composes || [];
|
|
715
|
+
for (const c of composes) {
|
|
716
|
+
if (!c.package_name) continue;
|
|
717
|
+
const safe = c.package_name.replace(/[/@]/g, '_');
|
|
718
|
+
const dir = path.join(piBaseDir, safe);
|
|
719
|
+
if (!fs.existsSync(dir)) {
|
|
720
|
+
process.stderr.write(` ${YELLOW}!${RESET} pi ingredient ${c.package_name} referenced by ${found.name} but not installed\n`);
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
ingredientDirs.push({ label: `${found.name} ← pi:${c.package_name}`, dir });
|
|
724
|
+
}
|
|
725
|
+
// Also check the pack's OWN source — hand-authored packs may shell
|
|
726
|
+
// out to system binaries (manim, docker, ffmpeg) or read env vars
|
|
727
|
+
// regardless of whether they compose any pi ingredients.
|
|
728
|
+
ingredientDirs.push({ label: found.name, dir: found.directory, isPack: true });
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const report = [];
|
|
732
|
+
let missingRequired = 0;
|
|
733
|
+
|
|
734
|
+
for (const { label, dir } of ingredientDirs) {
|
|
735
|
+
// Load or (re)compute the requirements sidecar.
|
|
736
|
+
let reqs = null;
|
|
737
|
+
const sidecar = path.join(dir, REQUIREMENTS_FILE);
|
|
738
|
+
if (fs.existsSync(sidecar)) {
|
|
739
|
+
try { reqs = JSON.parse(fs.readFileSync(sidecar, 'utf-8')); } catch { /* re-analyze */ }
|
|
740
|
+
}
|
|
741
|
+
if (!reqs) {
|
|
742
|
+
try {
|
|
743
|
+
let tools = null;
|
|
744
|
+
const toolsPath = path.join(dir, '.bahulam-tools.json');
|
|
745
|
+
if (fs.existsSync(toolsPath)) tools = JSON.parse(fs.readFileSync(toolsPath, 'utf-8'));
|
|
746
|
+
reqs = analyzeRequirements(dir, { discoveredTools: tools });
|
|
747
|
+
} catch (err) {
|
|
748
|
+
report.push({ label, dir, error: err.message });
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
const host = checkRequirementsAgainstHost(reqs);
|
|
753
|
+
const missing = host.binaries.filter(b => !b.found).length;
|
|
754
|
+
missingRequired += missing;
|
|
755
|
+
report.push({ label, dir, reqs, host, missing });
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
if (args.json) {
|
|
759
|
+
process.stdout.write(JSON.stringify({
|
|
760
|
+
ok: missingRequired === 0,
|
|
761
|
+
missing_binaries: missingRequired,
|
|
762
|
+
checked: report,
|
|
763
|
+
}, null, 2) + '\n');
|
|
764
|
+
if (missingRequired > 0) process.exit(1);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if (report.length === 0) {
|
|
769
|
+
process.stderr.write(`${DIM}No pi ingredients found to check.${RESET}\n`);
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
for (const r of report) {
|
|
774
|
+
process.stderr.write(`\n${BOLD}${CYAN}${r.label}${RESET} ${DIM}${r.dir}${RESET}\n`);
|
|
775
|
+
if (r.error) { process.stderr.write(` ${RED}✗${RESET} ${r.error}\n`); continue; }
|
|
776
|
+
const lines = formatRequirementsReport(r.reqs, { verbose: false });
|
|
777
|
+
for (const l of lines) {
|
|
778
|
+
const icon = l.level === 'warn' ? `${YELLOW}!${RESET}` : l.level === 'ok' ? `${GREEN}✓${RESET}` : `${DIM}·${RESET}`;
|
|
779
|
+
process.stderr.write(` ${icon} ${l.text}\n`);
|
|
780
|
+
}
|
|
781
|
+
if (r.host.binaries.length) {
|
|
782
|
+
process.stderr.write(` ${DIM}binaries:${RESET}\n`);
|
|
783
|
+
for (const b of r.host.binaries) {
|
|
784
|
+
if (b.found) {
|
|
785
|
+
process.stderr.write(` ${GREEN}✓${RESET} ${b.name}${b.version ? ` ${DIM}${b.version}${RESET}` : ''}${b.path ? ` ${DIM}${b.path}${RESET}` : ''}\n`);
|
|
786
|
+
} else {
|
|
787
|
+
const hint = b.install_hints?.[process.platform === 'darwin' ? 'darwin' : 'linux'];
|
|
788
|
+
process.stderr.write(` ${RED}✗${RESET} ${b.name}${hint ? ` ${DIM}install:${RESET} ${CYAN}${hint}${RESET}` : ''}\n`);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
if (r.host.env_vars.length) {
|
|
793
|
+
process.stderr.write(` ${DIM}env vars:${RESET}\n`);
|
|
794
|
+
for (const v of r.host.env_vars) {
|
|
795
|
+
const status = v.set ? `${GREEN}✓${RESET}` : (v.credential ? `${RED}✗${RESET}` : `${YELLOW}⚠${RESET}`);
|
|
796
|
+
process.stderr.write(` ${status} ${v.name}${v.credential ? ` ${DIM}(credential)${RESET}` : ''}\n`);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
process.stderr.write('\n');
|
|
801
|
+
if (missingRequired > 0) {
|
|
802
|
+
process.stderr.write(`${RED}✗${RESET} ${missingRequired} required binar${missingRequired === 1 ? 'y' : 'ies'} missing.\n\n`);
|
|
803
|
+
process.exit(1);
|
|
804
|
+
} else {
|
|
805
|
+
process.stderr.write(`${GREEN}✓${RESET} all detected requirements satisfied.\n\n`);
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
431
809
|
export async function handlePluginManagementCommand(args, { cwd = process.cwd(), throwOnError = false } = {}) {
|
|
432
810
|
try {
|
|
433
811
|
switch (args.action) {
|
|
434
|
-
case 'install': await cmdInstall(args, cwd); return;
|
|
435
812
|
case 'validate': case 'check': case 'lint': await cmdValidate(args, cwd); return;
|
|
436
|
-
case 'list': case 'ls': cmdList(args, cwd); return;
|
|
813
|
+
case 'list': case 'ls': await cmdList(args, cwd); return;
|
|
437
814
|
case 'remove': case 'rm': case 'uninstall': cmdRemove(args, cwd); return;
|
|
438
815
|
case 'enable': toggle(args, cwd, true); return;
|
|
439
816
|
case 'disable': toggle(args, cwd, false); return;
|
|
440
817
|
case 'info': cmdInfo(args, cwd); return;
|
|
818
|
+
case 'doctor': await cmdDoctor(args, cwd); return;
|
|
441
819
|
case 'update': case 'upgrade': await cmdUpdate(args, cwd); return;
|
|
442
820
|
default: throw new Error(`unknown plugin action: ${args.action}`);
|
|
443
821
|
}
|