@bahulam/code 0.1.13 → 0.1.15
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 +213 -4
- package/src/commands/plugin-manage.mjs +215 -29
- package/src/config/model-catalog-default.json +0 -4
- package/src/core/resume-mode.mjs +0 -1
- package/src/core/stream-client.mjs +18 -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/npm-install.mjs +138 -0
- package/src/plugins/pi-compat/requirements.mjs +465 -0
- package/src/plugins/pi-compat/scaffold.mjs +86 -10
- package/src/plugins/pi-compat/shim.mjs +29 -1
- package/src/plugins/preflight.mjs +2 -0
- package/src/terminal/main.mjs +5 -2
- package/src/terminal/repl.mjs +52 -23
- package/src/ui/input-dock.mjs +48 -10
- package/src/ui/text-layout.mjs +4 -3
package/package.json
CHANGED
package/src/commands/install.mjs
CHANGED
|
@@ -40,6 +40,9 @@ const CYAN = '\x1b[36m';
|
|
|
40
40
|
const GREEN = '\x1b[32m';
|
|
41
41
|
const YELLOW = '\x1b[33m';
|
|
42
42
|
|
|
43
|
+
const DEFAULT_BAHULAM_PLUGIN_REGISTRY_URL =
|
|
44
|
+
'https://raw.githubusercontent.com/BahulamAI/awesome-bahulam-plugins/main/registry.json';
|
|
45
|
+
|
|
43
46
|
function parseArgs(argv) {
|
|
44
47
|
const parsed = {
|
|
45
48
|
source: null,
|
|
@@ -152,10 +155,13 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
|
|
|
152
155
|
|
|
153
156
|
Install a pack. For pi sources, pulls the ingredient and scaffolds a
|
|
154
157
|
full Bahulam pack (composition + state layer + workspace + agent), then
|
|
155
|
-
installs it. For git/tarball/local sources, installs the
|
|
158
|
+
installs it. For registry/git/tarball/local sources, installs the
|
|
159
|
+
existing pack.
|
|
156
160
|
|
|
157
161
|
Sources:
|
|
158
162
|
pi:<npm-package>[@<version>] Scaffold + install from a pi package
|
|
163
|
+
<registry-name> Install from awesome-bahulam-plugins
|
|
164
|
+
bahulam:<registry-name> Explicit awesome-bahulam-plugins lookup
|
|
159
165
|
<git-url>[.git] Clone a hand-authored pack
|
|
160
166
|
<tarball-url> Download + install a pack tarball
|
|
161
167
|
<local-path> Copy + install a local pack directory
|
|
@@ -166,7 +172,7 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
|
|
|
166
172
|
--no-state Skip the persistent state layer (pi sources only)
|
|
167
173
|
--no-workspace Skip the reactive workspace panel (pi sources only)
|
|
168
174
|
--project Install into ./.bahulam/plugins/ instead of ~/.bahulam/plugins/
|
|
169
|
-
--ref <ref> Git branch/tag/commit (git sources
|
|
175
|
+
--ref <ref> Git branch/tag/commit (git or registry sources)
|
|
170
176
|
--json Machine-readable output
|
|
171
177
|
|
|
172
178
|
`);
|
|
@@ -186,6 +192,12 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
|
|
|
186
192
|
throw new Error(`unrecognized source: ${args.source}`);
|
|
187
193
|
}
|
|
188
194
|
|
|
195
|
+
const registryName = registryNameFromSource(args.source, classified);
|
|
196
|
+
if (registryName) {
|
|
197
|
+
await installFromBahulamRegistry({ name: registryName, targetDir, cwd, args });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
189
201
|
// Non-pi paths reuse the plugin-manage install machinery.
|
|
190
202
|
let dest;
|
|
191
203
|
if (classified.kind === 'git') {
|
|
@@ -194,8 +206,6 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
|
|
|
194
206
|
dest = await installFromTarball({ url: classified.url, targetDir, force: args.force });
|
|
195
207
|
} else if (classified.kind === 'local') {
|
|
196
208
|
dest = await installFromLocal({ src: classified.path, targetDir, force: args.force });
|
|
197
|
-
} else if (classified.kind === 'name') {
|
|
198
|
-
throw new Error(`registry lookup for bare names is not yet wired into \`bahulam install\`. Provide a git URL, tarball URL, local path, or pi: source.`);
|
|
199
209
|
} else {
|
|
200
210
|
throw new Error(`could not resolve source: ${args.source}`);
|
|
201
211
|
}
|
|
@@ -207,6 +217,118 @@ export async function handleInstallCommand(argv, { cwd = process.cwd() } = {}) {
|
|
|
207
217
|
}
|
|
208
218
|
}
|
|
209
219
|
|
|
220
|
+
export function registryNameFromSource(source, classified = null) {
|
|
221
|
+
const raw = String(source || '').trim();
|
|
222
|
+
const prefix = 'bahulam:';
|
|
223
|
+
if (raw.toLowerCase().startsWith(prefix)) {
|
|
224
|
+
const name = raw.slice(prefix.length).trim();
|
|
225
|
+
if (!name) {
|
|
226
|
+
throw new Error(`bahulam registry source requires a plugin name, e.g. ${CYAN}bahulam:manim-studio${RESET}`);
|
|
227
|
+
}
|
|
228
|
+
return name;
|
|
229
|
+
}
|
|
230
|
+
if (classified?.kind === 'name') return classified.name;
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function installFromBahulamRegistry({ name, targetDir, cwd, args }) {
|
|
235
|
+
const entry = await resolveBahulamRegistryPlugin(name, { cwd });
|
|
236
|
+
const repository = entry.repository || entry.repo || entry.url;
|
|
237
|
+
if (!repository) {
|
|
238
|
+
throw new Error(`registry entry "${entry.name}" is missing a repository URL`);
|
|
239
|
+
}
|
|
240
|
+
const ref = args.ref || entry.ref || null;
|
|
241
|
+
const subdir = entry.subdir || entry.path || null;
|
|
242
|
+
|
|
243
|
+
process.stderr.write(
|
|
244
|
+
`${DIM}registry${RESET} ${entry.name} → ${repository}` +
|
|
245
|
+
`${subdir ? `#${subdir}` : ''}${ref ? ` @ ${ref}` : ''}\n`,
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
const dest = await installFromGit({
|
|
249
|
+
url: repository,
|
|
250
|
+
targetDir,
|
|
251
|
+
name: entry.name,
|
|
252
|
+
ref,
|
|
253
|
+
subdir,
|
|
254
|
+
force: args.force,
|
|
255
|
+
});
|
|
256
|
+
await preflightAndReport({ dest, args, cwd });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function resolveBahulamRegistryPlugin(name, { cwd = process.cwd(), registry = null } = {}) {
|
|
260
|
+
const doc = registry || await loadBahulamPluginRegistry({ cwd });
|
|
261
|
+
const entries = Array.isArray(doc) ? doc : Array.isArray(doc?.plugins) ? doc.plugins : [];
|
|
262
|
+
if (!entries.length) {
|
|
263
|
+
throw new Error('Bahulam plugin registry did not contain any plugins');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const needle = normalizeRegistryName(name);
|
|
267
|
+
const entry = entries.find(item => {
|
|
268
|
+
const names = [
|
|
269
|
+
item?.name,
|
|
270
|
+
item?.slug,
|
|
271
|
+
item?.id,
|
|
272
|
+
...(Array.isArray(item?.aliases) ? item.aliases : []),
|
|
273
|
+
];
|
|
274
|
+
return names.some(candidate => normalizeRegistryName(candidate) === needle);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (!entry) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`plugin not found in Bahulam registry: ${name}\n` +
|
|
280
|
+
`Available: ${entries.map(item => item.name).filter(Boolean).join(', ') || '(none)'}`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return entry;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function loadBahulamPluginRegistry({ cwd = process.cwd() } = {}) {
|
|
287
|
+
const explicit =
|
|
288
|
+
process.env.BAHULAM_PLUGIN_REGISTRY ||
|
|
289
|
+
process.env.BAHULAM_PLUGIN_REGISTRY_PATH ||
|
|
290
|
+
process.env.BAHULAM_PLUGIN_REGISTRY_URL;
|
|
291
|
+
if (explicit) return readRegistryLocation(explicit, cwd);
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
return await readRegistryLocation(DEFAULT_BAHULAM_PLUGIN_REGISTRY_URL, cwd);
|
|
295
|
+
} catch (err) {
|
|
296
|
+
const localPath = findLocalRegistry(cwd);
|
|
297
|
+
if (localPath) return readRegistryJson(localPath);
|
|
298
|
+
throw new Error(`failed to load Bahulam plugin registry: ${err.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function readRegistryLocation(location, cwd) {
|
|
303
|
+
if (/^https?:\/\//i.test(location)) {
|
|
304
|
+
const res = await fetch(location, { headers: { accept: 'application/json' } });
|
|
305
|
+
if (!res.ok) throw new Error(`${location} returned HTTP ${res.status}`);
|
|
306
|
+
return res.json();
|
|
307
|
+
}
|
|
308
|
+
const filePath = path.isAbsolute(location) ? location : path.resolve(cwd, location);
|
|
309
|
+
return readRegistryJson(filePath);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function readRegistryJson(filePath) {
|
|
313
|
+
try {
|
|
314
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
315
|
+
} catch (err) {
|
|
316
|
+
throw new Error(`failed to read registry ${filePath}: ${err.message}`);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function findLocalRegistry(cwd) {
|
|
321
|
+
const candidates = [
|
|
322
|
+
path.resolve(cwd, '../awesome-bahulam-plugins/registry.json'),
|
|
323
|
+
path.resolve(cwd, 'awesome-bahulam-plugins/registry.json'),
|
|
324
|
+
];
|
|
325
|
+
return candidates.find(candidate => fs.existsSync(candidate)) || null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeRegistryName(name) {
|
|
329
|
+
return String(name || '').trim().replace(/^bahulam:/i, '').toLowerCase();
|
|
330
|
+
}
|
|
331
|
+
|
|
210
332
|
async function installPiWithScaffolding({ classified, targetDir, cwd, args }) {
|
|
211
333
|
const { bahulamHome } = await import('../core/paths.mjs');
|
|
212
334
|
const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
|
|
@@ -231,6 +353,12 @@ async function installPiWithScaffolding({ classified, targetDir, cwd, args }) {
|
|
|
231
353
|
// Step 2: ensure the tools cache is present.
|
|
232
354
|
const discovered = await discoverPiTools(piDir, { pluginName: classified.package_name });
|
|
233
355
|
|
|
356
|
+
// Step 2b: host check for required binaries. Install (unlike pull)
|
|
357
|
+
// implies "use this now", so a missing ffmpeg-class dep will fail at
|
|
358
|
+
// first tool call — better to fail loudly here. `--force` bypasses
|
|
359
|
+
// for offline provisioning / CI where deps land later.
|
|
360
|
+
await enforceHostRequirements({ piDir, packageName: classified.package_name, args });
|
|
361
|
+
|
|
234
362
|
// Step 3: generate the pack directory (composes + state + agent + panel).
|
|
235
363
|
process.stderr.write(`${DIM}scaffolding pack…${RESET}\n`);
|
|
236
364
|
const { dest, slug, namespace, exposeTools, agentSlug } = scaffoldPiPack({
|
|
@@ -267,6 +395,28 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
|
|
|
267
395
|
// Do it in one command; the scaffolder path already has the pi ingredient.
|
|
268
396
|
await resolveComposeDependencies(m, { targetDir: path.dirname(dest) });
|
|
269
397
|
|
|
398
|
+
// Host check for each composed pi ingredient (same policy as the
|
|
399
|
+
// scaffolder path). Blocks install if a required binary is missing.
|
|
400
|
+
const composes = m.spec?.composes || [];
|
|
401
|
+
if (composes.length && !meta) {
|
|
402
|
+
// meta present == scaffolder path already did this pre-scaffold
|
|
403
|
+
const { bahulamHome } = await import('../core/paths.mjs');
|
|
404
|
+
const piBaseDir = path.join(bahulamHome(), 'plugins-pi');
|
|
405
|
+
for (const compose of composes) {
|
|
406
|
+
if (!compose.package_name) continue;
|
|
407
|
+
const safeName = compose.package_name.replace(/[/@]/g, '_');
|
|
408
|
+
const piDir = path.join(piBaseDir, safeName);
|
|
409
|
+
if (!fs.existsSync(piDir)) continue;
|
|
410
|
+
try {
|
|
411
|
+
await enforceHostRequirements({ piDir, packageName: compose.package_name, args });
|
|
412
|
+
} catch (err) {
|
|
413
|
+
// Roll back the pack install — the composed dep won't work.
|
|
414
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
415
|
+
throw err;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
270
420
|
if (args.json) {
|
|
271
421
|
process.stdout.write(JSON.stringify({
|
|
272
422
|
ok: true,
|
|
@@ -293,3 +443,62 @@ async function preflightAndReport({ dest, args, cwd, meta = null }) {
|
|
|
293
443
|
}
|
|
294
444
|
process.stderr.write(`\n ${DIM}Open with:${RESET} ${CYAN}bahulam plugin ${m.metadata.name}${RESET}\n\n`);
|
|
295
445
|
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Install-time host check: read the ingredient's requirements sidecar,
|
|
449
|
+
* verify each detected binary is on PATH. Throws with an actionable
|
|
450
|
+
* message (per-OS install hints) if anything required is missing.
|
|
451
|
+
* `--force` bypasses (for CI, offline provisioning, dev workflows where
|
|
452
|
+
* deps land later).
|
|
453
|
+
*
|
|
454
|
+
* Env vars / credentials are warn-only — many pi tools have optional
|
|
455
|
+
* features and blocking on a missing PEXELS_API_KEY when the user only
|
|
456
|
+
* wants media_probe is too aggressive.
|
|
457
|
+
*/
|
|
458
|
+
async function enforceHostRequirements({ piDir, packageName, args }) {
|
|
459
|
+
const { checkRequirementsAgainstHost, REQUIREMENTS_FILE, analyzeRequirements } =
|
|
460
|
+
await import('../plugins/pi-compat/requirements.mjs');
|
|
461
|
+
|
|
462
|
+
const sidecar = path.join(piDir, REQUIREMENTS_FILE);
|
|
463
|
+
let reqs = null;
|
|
464
|
+
if (fs.existsSync(sidecar)) {
|
|
465
|
+
try { reqs = JSON.parse(fs.readFileSync(sidecar, 'utf-8')); } catch { /* re-analyze */ }
|
|
466
|
+
}
|
|
467
|
+
if (!reqs) {
|
|
468
|
+
// Sidecar was missing (older ingredient install or analyzer crash) —
|
|
469
|
+
// synthesize on the fly so the check is never silently skipped.
|
|
470
|
+
let discoveredTools = null;
|
|
471
|
+
const toolsCache = path.join(piDir, '.bahulam-tools.json');
|
|
472
|
+
if (fs.existsSync(toolsCache)) {
|
|
473
|
+
try { discoveredTools = JSON.parse(fs.readFileSync(toolsCache, 'utf-8')); } catch { /* ignore */ }
|
|
474
|
+
}
|
|
475
|
+
reqs = analyzeRequirements(piDir, { discoveredTools });
|
|
476
|
+
}
|
|
477
|
+
if (!reqs?.system_binaries?.length) {
|
|
478
|
+
// Nothing to check.
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const host = checkRequirementsAgainstHost(reqs);
|
|
483
|
+
const missing = host.binaries.filter(b => !b.found);
|
|
484
|
+
if (missing.length === 0) return;
|
|
485
|
+
|
|
486
|
+
const platformKey = process.platform === 'darwin' ? 'darwin' : 'linux';
|
|
487
|
+
const lines = [];
|
|
488
|
+
lines.push(`${packageName} needs ${missing.length} system binar${missing.length === 1 ? 'y' : 'ies'} not found on your PATH:`);
|
|
489
|
+
for (const b of missing) {
|
|
490
|
+
const hint = b.install_hints?.[platformKey];
|
|
491
|
+
lines.push(` · ${b.name}${hint ? ` — install: ${CYAN}${hint}${RESET}` : ''}`);
|
|
492
|
+
}
|
|
493
|
+
if (args.force) {
|
|
494
|
+
process.stderr.write(`${YELLOW}!${RESET} ${lines.join('\n')}\n`);
|
|
495
|
+
process.stderr.write(`${YELLOW}!${RESET} ${DIM}--force set — continuing anyway. Composed tools using these binaries will fail at first call.${RESET}\n`);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const err = new Error(
|
|
499
|
+
`${lines.join('\n')}\n\n` +
|
|
500
|
+
`Install the missing binaries, then rerun. Or use ${CYAN}--force${RESET} to install without them (composed tools using these will fail at first call).\n` +
|
|
501
|
+
`Verify anytime with: ${CYAN}bahulam plugin doctor pi:${packageName}${RESET}`,
|
|
502
|
+
);
|
|
503
|
+
throw err;
|
|
504
|
+
}
|
|
@@ -206,6 +206,7 @@ export async function installFromGit({ url, targetDir, name, ref, subdir, force
|
|
|
206
206
|
await run('git', ['clone', '--depth', '1', ...(ref ? ['--branch', ref] : []), url, dest]);
|
|
207
207
|
}
|
|
208
208
|
writeStamp(dest, { origin: { kind: 'git', url, ref: ref || null, subdir: subdir || null } });
|
|
209
|
+
await installPackNpmDeps(dest, name);
|
|
209
210
|
return dest;
|
|
210
211
|
}
|
|
211
212
|
|
|
@@ -226,10 +227,53 @@ export async function installFromTarball({ url, targetDir, name, force }) {
|
|
|
226
227
|
else await run('tar', ['-xzf', tmp, '-C', dest, '--strip-components=1']);
|
|
227
228
|
fs.unlinkSync(tmp);
|
|
228
229
|
writeStamp(dest, { origin: { kind: 'tarball', url } });
|
|
230
|
+
await installPackNpmDeps(dest, guessed);
|
|
229
231
|
return dest;
|
|
230
232
|
}
|
|
231
233
|
|
|
232
|
-
|
|
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 }) {
|
|
233
277
|
// Pi packages live in ~/.bahulam/plugins-pi/ (or $BAHULAM_HOME/plugins-pi/)
|
|
234
278
|
// so `bahulam plugin list` doesn't confuse them with our own packs. The
|
|
235
279
|
// tool executor reads from the same canonical path via bahulamHome().
|
|
@@ -255,31 +299,17 @@ export async function installFromPi({ packageName, versionRange, force }) {
|
|
|
255
299
|
if (!tarballs.length) throw new Error(`npm pack produced no tarball for ${spec}`);
|
|
256
300
|
await run('tar', ['-xzf', path.join(tmp, tarballs[0]), '-C', dest, '--strip-components=1']);
|
|
257
301
|
|
|
258
|
-
//
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
}
|
|
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
|
+
});
|
|
283
313
|
|
|
284
314
|
// Read the resolved version so the stamp captures what we actually got.
|
|
285
315
|
let resolvedVersion = null;
|
|
@@ -303,15 +333,36 @@ export async function installFromPi({ packageName, versionRange, force }) {
|
|
|
303
333
|
// `cat` and lets executor invocations skip the first-run probe cost.
|
|
304
334
|
// Best-effort: failure here is non-fatal (returns tools:[] until next
|
|
305
335
|
// invocation retries) so a broken extension can still be diagnosed.
|
|
336
|
+
let discoveredShape = null;
|
|
306
337
|
try {
|
|
307
338
|
const { discoverPiTools } = await import('../plugins/pi-compat/probe.mjs');
|
|
308
|
-
|
|
309
|
-
process.stderr.write(` ${DIM}discovered${RESET} ${(
|
|
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`);
|
|
310
341
|
} catch (probeErr) {
|
|
311
342
|
process.stderr.write(` ${YELLOW}!${RESET} Tool discovery failed: ${probeErr.message}\n`);
|
|
312
343
|
process.stderr.write(` ${DIM}The package installed but no tools were probed. Re-probe with:${RESET}\n`);
|
|
313
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`);
|
|
314
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
|
+
}
|
|
315
366
|
} catch (err) {
|
|
316
367
|
rmrf(dest);
|
|
317
368
|
throw new Error(`pi install failed for ${spec}: ${err.message}`);
|
|
@@ -330,8 +381,11 @@ export async function installFromLocal({ src, targetDir, force }) {
|
|
|
330
381
|
rmrf(dest);
|
|
331
382
|
}
|
|
332
383
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
333
|
-
|
|
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' });
|
|
334
387
|
writeStamp(dest, { origin: { kind: 'local', path: src } });
|
|
388
|
+
await installPackNpmDeps(dest, name);
|
|
335
389
|
return dest;
|
|
336
390
|
}
|
|
337
391
|
|
|
@@ -621,6 +675,137 @@ async function cmdValidate(args, cwd) {
|
|
|
621
675
|
if (!result.ok) process.exit(1);
|
|
622
676
|
}
|
|
623
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
|
+
|
|
624
809
|
export async function handlePluginManagementCommand(args, { cwd = process.cwd(), throwOnError = false } = {}) {
|
|
625
810
|
try {
|
|
626
811
|
switch (args.action) {
|
|
@@ -630,6 +815,7 @@ export async function handlePluginManagementCommand(args, { cwd = process.cwd(),
|
|
|
630
815
|
case 'enable': toggle(args, cwd, true); return;
|
|
631
816
|
case 'disable': toggle(args, cwd, false); return;
|
|
632
817
|
case 'info': cmdInfo(args, cwd); return;
|
|
818
|
+
case 'doctor': await cmdDoctor(args, cwd); return;
|
|
633
819
|
case 'update': case 'upgrade': await cmdUpdate(args, cwd); return;
|
|
634
820
|
default: throw new Error(`unknown plugin action: ${args.action}`);
|
|
635
821
|
}
|
|
@@ -33,9 +33,6 @@
|
|
|
33
33
|
{"value": "qwen/qwq-32b", "label": "QwQ 32B", "provider": "qwen", "inputCost": 0.2, "outputCost": 0.2, "context": 131072, "maxOutput": 32768, "supportsTools": true, "supportsReasoning": true, "harnessValidated": false},
|
|
34
34
|
{"value": "minimax/minimax-m3", "label": "MiniMax M3", "provider": "minimax", "inputCost": 0.5, "outputCost": 2.0, "context": 1048576, "maxOutput": 512000, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
35
35
|
{"value": "minimax/minimax-m2.1", "label": "MiniMax M2.1", "provider": "minimax", "inputCost": 0.3, "outputCost": 1.1, "context": 204800, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
36
|
-
{"value": "xiaomi/mimo-v2.5-pro", "label": "MiMo V2.5 Pro", "provider": "xiaomi", "inputCost": 0.5, "outputCost": 2.0, "context": 1050000, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
37
|
-
{"value": "xiaomi/mimo-v2.5", "label": "MiMo V2.5", "provider": "xiaomi", "inputCost": 0.3, "outputCost": 1.0, "context": 1050000, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"Xiaomi\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
38
|
-
{"value": "xiaomi/mimo-v2-flash", "label": "MiMo V2 Flash", "provider": "xiaomi", "inputCost": 0.1, "outputCost": 0.3, "context": 131072, "maxOutput": 16384, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
39
36
|
{"value": "moonshotai/kimi-k2.5", "label": "Kimi K2.5", "provider": "kimi", "inputCost": 0.44, "outputCost": 2.0, "context": 262144, "maxOutput": 262144, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
40
37
|
{"value": "moonshotai/kimi-k2", "label": "Kimi K2", "provider": "kimi", "inputCost": 0.44, "outputCost": 2.0, "context": 131072, "maxOutput": 100352, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
41
38
|
{"value": "moonshotai/kimi-k2-instruct", "label": "Kimi K2 Instruct", "provider": "kimi", "inputCost": 0.44, "outputCost": 2.0, "context": 131072, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
@@ -60,7 +57,6 @@
|
|
|
60
57
|
{"value": "stealth/ox-alpha", "label": "Stealth OX Alpha", "provider": "stealth", "inputCost": 0.3, "outputCost": 1.0, "context": 1048576, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"Stealth\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
61
58
|
{"value": "upstage/solar-pro4", "label": "Solar Pro 4", "provider": "upstage", "inputCost": 0.3, "outputCost": 1.0, "context": 524288, "maxOutput": 131072, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"Upstage\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
|
62
59
|
{"value": "qwen/qwen3-coder:free", "label": "Qwen3 Coder (Free)", "provider": "qwen", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
63
|
-
{"value": "xiaomi/mimo-v2-flash:free", "label": "MiMo V2 Flash (Free)", "provider": "xiaomi", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 16384, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
64
60
|
{"value": "meta-llama/llama-3.3-70b-instruct:free", "label": "Llama 3.3 70B (Free)", "provider": "meta", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 8192, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
65
61
|
{"value": "qwen/qwen3-8b:free", "label": "Qwen3 8B (Free)", "provider": "qwen", "inputCost": 0, "outputCost": 0, "context": 131072, "maxOutput": 8192, "supportsTools": true, "supportsReasoning": false, "harnessValidated": false},
|
|
66
62
|
{"value": "nvidia/nemotron-3-ultra-550b-a55b:free", "label": "Nemotron 3 Ultra 550B (Free)", "provider": "nvidia", "inputCost": 0, "outputCost": 0, "context": 1000000, "maxOutput": 65536, "supportsTools": true, "supportsReasoning": false, "harnessValidated": true, "cacheProfile": "{\"type\": \"prefix_hash\", \"provider_hint\": \"NVIDIA\"}", "platformAccessTier": ["pro", "tier_49", "tier_99"]},
|
package/src/core/resume-mode.mjs
CHANGED
|
@@ -171,6 +171,7 @@ export class BahulamStreamClient {
|
|
|
171
171
|
this._pauseWaiters = new Set();
|
|
172
172
|
this._abort = null;
|
|
173
173
|
this._toolAbort = null;
|
|
174
|
+
this._sawComplete = false;
|
|
174
175
|
|
|
175
176
|
// Transport mode:
|
|
176
177
|
// 'remote' → cloud backend runs the agent loop server-side.
|
|
@@ -352,6 +353,7 @@ export class BahulamStreamClient {
|
|
|
352
353
|
this._cancelled = false;
|
|
353
354
|
this.currentTaskId = null;
|
|
354
355
|
this._firstEventTimedOut = false;
|
|
356
|
+
this._sawComplete = false;
|
|
355
357
|
|
|
356
358
|
// Bundled mode: spawn the local Python runtime on first turn.
|
|
357
359
|
// After this, this.baseUrl points at http://127.0.0.1:<random-port>
|
|
@@ -500,6 +502,21 @@ export class BahulamStreamClient {
|
|
|
500
502
|
if (this._cancelled) {
|
|
501
503
|
return;
|
|
502
504
|
}
|
|
505
|
+
if (this._sawComplete) {
|
|
506
|
+
telemetry.track('stream.post_complete_error_ignored', {
|
|
507
|
+
task_id: this.currentTaskId || null,
|
|
508
|
+
last_event_id: this.lastEventId || null,
|
|
509
|
+
message: err?.message || 'stream closed after complete',
|
|
510
|
+
error_code: err?.cause?.code || err?.code || '',
|
|
511
|
+
});
|
|
512
|
+
transportDebug('execute.post_complete_error_ignored', {
|
|
513
|
+
task_id: this.currentTaskId || null,
|
|
514
|
+
last_event_id: this.lastEventId || null,
|
|
515
|
+
error: err?.message || 'stream closed after complete',
|
|
516
|
+
code: err?.cause?.code || err?.code || '',
|
|
517
|
+
});
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
503
520
|
if (this._firstEventTimedOut) {
|
|
504
521
|
yield {
|
|
505
522
|
type: EVENT_TYPES.ERROR,
|
|
@@ -579,6 +596,7 @@ export class BahulamStreamClient {
|
|
|
579
596
|
}
|
|
580
597
|
|
|
581
598
|
if (event === EVENT_TYPES.COMPLETE) {
|
|
599
|
+
this._sawComplete = true;
|
|
582
600
|
this._persistMemoryFactsFromComplete(data);
|
|
583
601
|
}
|
|
584
602
|
|