@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
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static requirements analyzer for an installed pi ingredient.
|
|
3
|
+
*
|
|
4
|
+
* Walks the package's source files and detects the failure-mode signals
|
|
5
|
+
* a composed pack will hit at first tool call: shell binaries the pi
|
|
6
|
+
* package spawns, env vars / API keys it reads, workspace-scoping of
|
|
7
|
+
* paths, and per-tool schema constraints already surfaced by the probe.
|
|
8
|
+
*
|
|
9
|
+
* Emits `<pi-dir>/.bahulam-requirements.json` alongside the tool cache.
|
|
10
|
+
* Runs after `discoverPiTools` during pull/install so the CLI can print
|
|
11
|
+
* findings before the user commits to using the pack.
|
|
12
|
+
*
|
|
13
|
+
* Explicitly not-an-LLM: uses regex + JSON reads + Markdown section
|
|
14
|
+
* matching. Fast (< 500ms for a big package), safe, no network. If a
|
|
15
|
+
* requirement slips past the static heuristics, the user pastes the
|
|
16
|
+
* error into Bahulam and the main agent reasons about it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import { execSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
export const REQUIREMENTS_FILE = '.bahulam-requirements.json';
|
|
24
|
+
|
|
25
|
+
// Known binary → install hint DB. Extend as we learn new pi packages.
|
|
26
|
+
// Keep it small and honest: unknown binaries just report the name.
|
|
27
|
+
const INSTALL_HINTS = {
|
|
28
|
+
ffmpeg: { darwin: 'brew install ffmpeg', linux: 'apt install -y ffmpeg' },
|
|
29
|
+
ffprobe: { darwin: 'brew install ffmpeg', linux: 'apt install -y ffmpeg' },
|
|
30
|
+
imagemagick: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick' },
|
|
31
|
+
convert: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick' },
|
|
32
|
+
magick: { darwin: 'brew install imagemagick', linux: 'apt install -y imagemagick' },
|
|
33
|
+
docker: { darwin: 'brew install --cask docker', linux: 'https://docs.docker.com/engine/install/' },
|
|
34
|
+
git: { darwin: 'brew install git', linux: 'apt install -y git' },
|
|
35
|
+
python: { darwin: 'brew install python', linux: 'apt install -y python3' },
|
|
36
|
+
python3: { darwin: 'brew install python', linux: 'apt install -y python3' },
|
|
37
|
+
node: { darwin: 'brew install node', linux: 'apt install -y nodejs' },
|
|
38
|
+
yt_dlp: { darwin: 'brew install yt-dlp', linux: 'pip install yt-dlp' },
|
|
39
|
+
'yt-dlp': { darwin: 'brew install yt-dlp', linux: 'pip install yt-dlp' },
|
|
40
|
+
pandoc: { darwin: 'brew install pandoc', linux: 'apt install -y pandoc' },
|
|
41
|
+
tesseract: { darwin: 'brew install tesseract', linux: 'apt install -y tesseract-ocr' },
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Shell keywords that mean "the arg after me is the binary" when
|
|
45
|
+
// child_process is invoked with a shell wrapper like `sh -c '...'`.
|
|
46
|
+
const SHELL_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'cmd', 'cmd.exe', 'powershell', 'pwsh']);
|
|
47
|
+
|
|
48
|
+
// Env var names we treat as "credential-ish" by heuristic. Not exhaustive —
|
|
49
|
+
// falls back to any UPPER_SNAKE ending in these tokens.
|
|
50
|
+
const CREDENTIAL_SUFFIXES = ['_KEY', '_TOKEN', '_SECRET', '_PASSWORD', '_APIKEY', '_API_KEY', '_ACCESS_KEY', '_ACCESS_TOKEN'];
|
|
51
|
+
|
|
52
|
+
// Files we scan. .ts/.mjs/.js/.cjs — pi packages sometimes ship pure TS.
|
|
53
|
+
const SOURCE_EXT_RE = /\.(ts|tsx|mts|cts|mjs|cjs|js)$/;
|
|
54
|
+
|
|
55
|
+
// Directories we skip (bloat + third-party code we don't want to attribute
|
|
56
|
+
// as pi's requirements).
|
|
57
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', 'coverage', 'tests', '__tests__', 'test']);
|
|
58
|
+
|
|
59
|
+
// Cap the scan so an accidentally-huge package doesn't hang.
|
|
60
|
+
const MAX_FILES = 400;
|
|
61
|
+
const MAX_FILE_BYTES = 512 * 1024; // skip anything > 512KB (minified bundles)
|
|
62
|
+
|
|
63
|
+
function walkSources(dir, out, budget) {
|
|
64
|
+
if (out.length >= MAX_FILES) return;
|
|
65
|
+
let entries;
|
|
66
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
|
|
67
|
+
catch { return; }
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
if (out.length >= MAX_FILES) return;
|
|
70
|
+
const full = path.join(dir, entry.name);
|
|
71
|
+
if (entry.isDirectory()) {
|
|
72
|
+
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.')) continue;
|
|
73
|
+
walkSources(full, out, budget);
|
|
74
|
+
} else if (entry.isFile() && SOURCE_EXT_RE.test(entry.name)) {
|
|
75
|
+
try {
|
|
76
|
+
const stat = fs.statSync(full);
|
|
77
|
+
if (stat.size > MAX_FILE_BYTES) continue;
|
|
78
|
+
out.push(full);
|
|
79
|
+
} catch { /* skip */ }
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function readTextSafe(p) {
|
|
85
|
+
try { return fs.readFileSync(p, 'utf-8'); } catch { return ''; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Extract the binary name from a spawn/exec/execFile call. Handles:
|
|
90
|
+
* spawn('ffmpeg', ...) → ffmpeg
|
|
91
|
+
* spawn("bash", ["-c", "ff"]) → ffmpeg (peek shell args)
|
|
92
|
+
* exec('ffprobe -v error ...') → ffprobe
|
|
93
|
+
* execFile("./bin/foo", ...) → foo (skip — local script, not a system dep)
|
|
94
|
+
* spawn(cmd, ...) → skipped (variable, we can't infer)
|
|
95
|
+
*/
|
|
96
|
+
function extractBinaryFromCall(callKind, argText) {
|
|
97
|
+
const trimmed = argText.trim();
|
|
98
|
+
const m = trimmed.match(/^(['"`])(.+?)\1/);
|
|
99
|
+
if (!m) return null;
|
|
100
|
+
let first = m[2].trim();
|
|
101
|
+
|
|
102
|
+
if (callKind === 'exec' || callKind === 'execSync') {
|
|
103
|
+
// First token of a shell command line.
|
|
104
|
+
const token = first.split(/\s+/)[0] || '';
|
|
105
|
+
const clean = token.replace(/^['"]|['"]$/g, '');
|
|
106
|
+
const base = path.basename(clean).replace(/\.(exe|bat|cmd)$/i, '');
|
|
107
|
+
if (!base || base.startsWith('/') || base.startsWith('.') || base.startsWith('$')) return null;
|
|
108
|
+
if (!/^[a-zA-Z0-9_.+-]+$/.test(base)) return null;
|
|
109
|
+
return base;
|
|
110
|
+
}
|
|
111
|
+
if (callKind === 'spawn' || callKind === 'spawnSync' || callKind === 'execFile' || callKind === 'execFileSync') {
|
|
112
|
+
const base = path.basename(first).replace(/\.(exe|bat|cmd)$/i, '');
|
|
113
|
+
if (!base || first.startsWith('./') || first.startsWith('../') || first.startsWith('/')) {
|
|
114
|
+
// Local path — a bundled script, not a system requirement
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
if (!/^[a-zA-Z0-9_.+-]+$/.test(base)) return null;
|
|
118
|
+
// Special-case shell wrappers: peek the args array for the real binary
|
|
119
|
+
if (SHELL_WRAPPERS.has(base)) {
|
|
120
|
+
const argsMatch = trimmed.match(/,\s*\[(.*?)\]/s);
|
|
121
|
+
if (argsMatch) {
|
|
122
|
+
const argsText = argsMatch[1];
|
|
123
|
+
// Look for the first quoted arg after a "-c" flag
|
|
124
|
+
const cMatch = argsText.match(/['"`]-c['"`]\s*,\s*['"`]([^'"`]+)['"`]/);
|
|
125
|
+
if (cMatch) {
|
|
126
|
+
const innerBin = cMatch[1].trim().split(/\s+/)[0] || '';
|
|
127
|
+
const innerBase = path.basename(innerBin).replace(/\.(exe|bat|cmd)$/i, '');
|
|
128
|
+
if (innerBase && /^[a-zA-Z0-9_.+-]+$/.test(innerBase)) return innerBase;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
return base;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Scan a single source file for shell/env/path signals. Purely textual —
|
|
140
|
+
* we skip AST parsing to keep the analyzer dependency-free and fast.
|
|
141
|
+
* False positives are acceptable; the alternative (missing a real dep)
|
|
142
|
+
* is worse. Users see a list they can eyeball.
|
|
143
|
+
*/
|
|
144
|
+
// Binary names we recognize in string literals or env var infixes when
|
|
145
|
+
// direct spawn arg detection misses (spawn(variable, ...) is common).
|
|
146
|
+
const KNOWN_BINARY_TOKENS = ['ffmpeg', 'ffprobe', 'imagemagick', 'convert', 'magick', 'docker', 'git', 'python', 'python3', 'node', 'yt-dlp', 'ytdlp', 'pandoc', 'tesseract', 'sox', 'gs', 'ghostscript', 'poppler', 'pdftotext', 'pdfinfo', 'lame', 'oggenc', 'flac', 'curl', 'wget', 'rsync', 'jq', 'yq', 'awk', 'sed'];
|
|
147
|
+
|
|
148
|
+
function analyzeFile(text, rel, findings) {
|
|
149
|
+
// Shell binary calls: spawn/spawnSync/exec/execSync/execFile/execFileSync
|
|
150
|
+
const shellRe = /\b(spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(\s*([^)]{0,300})\)/g;
|
|
151
|
+
let m;
|
|
152
|
+
let sawChildProcess = /\bfrom\s*['"]node:child_process['"]|require\(\s*['"]node:child_process['"]/.test(text)
|
|
153
|
+
|| /\bfrom\s*['"]child_process['"]|require\(\s*['"]child_process['"]/.test(text);
|
|
154
|
+
while ((m = shellRe.exec(text)) !== null) {
|
|
155
|
+
sawChildProcess = true;
|
|
156
|
+
const kind = m[1];
|
|
157
|
+
const bin = extractBinaryFromCall(kind, m[2]);
|
|
158
|
+
if (!bin) continue;
|
|
159
|
+
const existing = findings.systemBinaries.get(bin) || { name: bin, seen_in: new Set() };
|
|
160
|
+
existing.seen_in.add(rel);
|
|
161
|
+
findings.systemBinaries.set(bin, existing);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Env vars: process.env.XXX or process.env['XXX']
|
|
165
|
+
const envRe = /\bprocess\.env\s*(?:\.([A-Z][A-Z0-9_]{2,})|\[\s*['"`]([A-Z][A-Z0-9_]{2,})['"`]\s*\])/g;
|
|
166
|
+
while ((m = envRe.exec(text)) !== null) {
|
|
167
|
+
const name = m[1] || m[2];
|
|
168
|
+
if (!name) continue;
|
|
169
|
+
// Ignore Node.js / OS-level env vars users don't set for a plugin.
|
|
170
|
+
if (['NODE_ENV', 'PATH', 'HOME', 'USER', 'PWD', 'SHELL', 'TERM', 'LANG', 'LC_ALL', 'TZ', 'TMPDIR', 'TMP', 'TEMP'].includes(name)) continue;
|
|
171
|
+
const existing = findings.envVars.get(name) || { name, seen_in: new Set(), credential: false };
|
|
172
|
+
existing.seen_in.add(rel);
|
|
173
|
+
if (CREDENTIAL_SUFFIXES.some(s => name.endsWith(s))) existing.credential = true;
|
|
174
|
+
findings.envVars.set(name, existing);
|
|
175
|
+
|
|
176
|
+
// If an env var name contains a known binary token (e.g. PI_MEDIA_FFMPEG_BINARY,
|
|
177
|
+
// FFPROBE_PATH, IMAGEMAGICK_HOME), the package is almost certainly
|
|
178
|
+
// shelling out to that binary — even when the actual spawn() takes a
|
|
179
|
+
// variable, not a literal. This catches the common override-your-binary
|
|
180
|
+
// pattern many pi packages use.
|
|
181
|
+
const lower = name.toLowerCase();
|
|
182
|
+
for (const tok of KNOWN_BINARY_TOKENS) {
|
|
183
|
+
if (lower.includes(tok.replace('-', '_'))) {
|
|
184
|
+
const existing2 = findings.systemBinaries.get(tok) || { name: tok, seen_in: new Set() };
|
|
185
|
+
existing2.seen_in.add(`inferred from env var ${name}`);
|
|
186
|
+
findings.systemBinaries.set(tok, existing2);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Fallback: if child_process is imported but spawn args are variables
|
|
192
|
+
// (spawn(cmd, ...)) rather than literals, look for known binary names as
|
|
193
|
+
// string literals anywhere in the file. False-positive risk (a comment or
|
|
194
|
+
// an error message could mention 'ffmpeg'), but the alternative is
|
|
195
|
+
// missing the requirement entirely — users can review the findings list.
|
|
196
|
+
if (sawChildProcess && findings.systemBinaries.size === 0) {
|
|
197
|
+
for (const tok of KNOWN_BINARY_TOKENS) {
|
|
198
|
+
const litRe = new RegExp(`['"\`]${tok.replace(/[-]/g, '\\-')}['"\`]`, 'i');
|
|
199
|
+
if (litRe.test(text)) {
|
|
200
|
+
const existing = findings.systemBinaries.get(tok) || { name: tok, seen_in: new Set() };
|
|
201
|
+
existing.seen_in.add(`literal in ${rel}`);
|
|
202
|
+
findings.systemBinaries.set(tok, existing);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Workspace-scoping heuristic: the pi convention is `ctx.cwd` used in a
|
|
208
|
+
// path resolve/join, followed by a throw about "outside" or "workspace".
|
|
209
|
+
if (!findings.workspaceScopedPaths) {
|
|
210
|
+
if (/ctx\s*\.\s*cwd/.test(text) && /(outside|not.*inside|workspace)/i.test(text) && /throw\b/.test(text)) {
|
|
211
|
+
findings.workspaceScopedPaths = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function analyzePackageJson(pkg, findings) {
|
|
217
|
+
// Author-declared runtime hints.
|
|
218
|
+
const opt = pkg.optionalDependencies || {};
|
|
219
|
+
for (const name of Object.keys(opt)) {
|
|
220
|
+
// Heuristic: optionalDependencies named after known binaries.
|
|
221
|
+
if (INSTALL_HINTS[name]) {
|
|
222
|
+
const existing = findings.systemBinaries.get(name) || { name, seen_in: new Set(['package.json:optionalDependencies']) };
|
|
223
|
+
findings.systemBinaries.set(name, existing);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// engines / os could add platform hints — record but don't overweight.
|
|
227
|
+
if (pkg.engines) findings.enginesRequirement = pkg.engines;
|
|
228
|
+
if (pkg.os) findings.osRequirement = pkg.os;
|
|
229
|
+
if (pkg.cpu) findings.cpuRequirement = pkg.cpu;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function extractReadmeSections(readmeText) {
|
|
233
|
+
if (!readmeText) return [];
|
|
234
|
+
const sections = [];
|
|
235
|
+
const wanted = /^\s*#{1,3}\s+(requirements?|prerequisites?|installation|setup|dependencies|before you begin)\b/i;
|
|
236
|
+
const lines = readmeText.split('\n');
|
|
237
|
+
let inSection = false;
|
|
238
|
+
let title = '';
|
|
239
|
+
let body = [];
|
|
240
|
+
const flush = () => {
|
|
241
|
+
if (inSection) {
|
|
242
|
+
const text = body.join('\n').trim();
|
|
243
|
+
if (text) sections.push({ title: title.trim(), body: text.slice(0, 2000) });
|
|
244
|
+
}
|
|
245
|
+
inSection = false; title = ''; body = [];
|
|
246
|
+
};
|
|
247
|
+
for (const line of lines) {
|
|
248
|
+
if (wanted.test(line)) {
|
|
249
|
+
flush();
|
|
250
|
+
inSection = true;
|
|
251
|
+
title = line.replace(/^\s*#+\s*/, '');
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (inSection && /^\s*#{1,3}\s/.test(line)) { flush(); continue; }
|
|
255
|
+
if (inSection) body.push(line);
|
|
256
|
+
}
|
|
257
|
+
flush();
|
|
258
|
+
return sections;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function extractSkillsFiles(pluginDir) {
|
|
262
|
+
const skills = [];
|
|
263
|
+
const dirs = ['skills', 'SKILLS'];
|
|
264
|
+
for (const d of dirs) {
|
|
265
|
+
const full = path.join(pluginDir, d);
|
|
266
|
+
try {
|
|
267
|
+
const stat = fs.statSync(full);
|
|
268
|
+
if (!stat.isDirectory()) continue;
|
|
269
|
+
for (const f of fs.readdirSync(full)) {
|
|
270
|
+
if (/\.(md|markdown|mdx)$/i.test(f)) skills.push(path.join(d, f));
|
|
271
|
+
}
|
|
272
|
+
} catch { /* skip */ }
|
|
273
|
+
}
|
|
274
|
+
// Root-level SKILL.md
|
|
275
|
+
for (const name of ['SKILL.md', 'skill.md', 'AGENTS.md']) {
|
|
276
|
+
if (fs.existsSync(path.join(pluginDir, name))) skills.push(name);
|
|
277
|
+
}
|
|
278
|
+
return skills;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function extractToolConstraints(discoveredTools) {
|
|
282
|
+
const out = {};
|
|
283
|
+
for (const tool of discoveredTools || []) {
|
|
284
|
+
const s = tool.input_schema || {};
|
|
285
|
+
const props = s.properties || {};
|
|
286
|
+
const required = new Set(s.required || []);
|
|
287
|
+
const perParam = {};
|
|
288
|
+
for (const [name, def] of Object.entries(props)) {
|
|
289
|
+
const c = {};
|
|
290
|
+
if (def.pattern) c.regex = def.pattern;
|
|
291
|
+
if (typeof def.minLength === 'number') c.min_length = def.minLength;
|
|
292
|
+
if (typeof def.maxLength === 'number') c.max_length = def.maxLength;
|
|
293
|
+
if (typeof def.minimum === 'number') c.min = def.minimum;
|
|
294
|
+
if (typeof def.maximum === 'number') c.max = def.maximum;
|
|
295
|
+
if (Array.isArray(def.enum)) c.enum = def.enum;
|
|
296
|
+
if (required.has(name)) c.required = true;
|
|
297
|
+
if (Object.keys(c).length) perParam[name] = c;
|
|
298
|
+
}
|
|
299
|
+
if (Object.keys(perParam).length) out[tool.name] = perParam;
|
|
300
|
+
}
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Analyze an installed pi ingredient. Returns the requirements object
|
|
306
|
+
* AND writes it to `<pluginDir>/.bahulam-requirements.json`.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} pluginDir — absolute path to the ingredient dir
|
|
309
|
+
* @param {Object} [opts]
|
|
310
|
+
* @param {Object} [opts.discoveredTools] — parsed .bahulam-tools.json, if
|
|
311
|
+
* the caller already has it. Otherwise skips tool-constraint extraction.
|
|
312
|
+
*/
|
|
313
|
+
export function analyzeRequirements(pluginDir, { discoveredTools = null } = {}) {
|
|
314
|
+
const findings = {
|
|
315
|
+
systemBinaries: new Map(),
|
|
316
|
+
envVars: new Map(),
|
|
317
|
+
workspaceScopedPaths: false,
|
|
318
|
+
enginesRequirement: null,
|
|
319
|
+
osRequirement: null,
|
|
320
|
+
cpuRequirement: null,
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
// Package.json hints
|
|
324
|
+
const pkgPath = path.join(pluginDir, 'package.json');
|
|
325
|
+
let pkg = {};
|
|
326
|
+
try { pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8')); } catch { /* skip */ }
|
|
327
|
+
analyzePackageJson(pkg, findings);
|
|
328
|
+
|
|
329
|
+
// Walk sources
|
|
330
|
+
const files = [];
|
|
331
|
+
walkSources(pluginDir, files, MAX_FILES);
|
|
332
|
+
for (const abs of files) {
|
|
333
|
+
const rel = path.relative(pluginDir, abs);
|
|
334
|
+
analyzeFile(readTextSafe(abs), rel, findings);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// README requirements
|
|
338
|
+
const readmeCandidates = ['README.md', 'readme.md', 'README', 'readme.markdown'];
|
|
339
|
+
let readmeText = '';
|
|
340
|
+
for (const r of readmeCandidates) {
|
|
341
|
+
const full = path.join(pluginDir, r);
|
|
342
|
+
if (fs.existsSync(full)) { readmeText = readTextSafe(full); break; }
|
|
343
|
+
}
|
|
344
|
+
const readmeSections = extractReadmeSections(readmeText);
|
|
345
|
+
|
|
346
|
+
// Skills discovery
|
|
347
|
+
const skills = extractSkillsFiles(pluginDir);
|
|
348
|
+
|
|
349
|
+
// Tool constraints from probed schemas (caller supplies)
|
|
350
|
+
const toolConstraints = discoveredTools ? extractToolConstraints(discoveredTools.tools || []) : {};
|
|
351
|
+
|
|
352
|
+
// Assemble the sidecar
|
|
353
|
+
const shape = {
|
|
354
|
+
version: 1,
|
|
355
|
+
analyzed_at: new Date().toISOString(),
|
|
356
|
+
files_scanned: files.length,
|
|
357
|
+
truncated: files.length >= MAX_FILES,
|
|
358
|
+
system_binaries: [...findings.systemBinaries.values()]
|
|
359
|
+
.map(v => ({
|
|
360
|
+
name: v.name,
|
|
361
|
+
install_hints: INSTALL_HINTS[v.name] || null,
|
|
362
|
+
seen_in: [...v.seen_in].slice(0, 5),
|
|
363
|
+
}))
|
|
364
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
365
|
+
env_vars: [...findings.envVars.values()]
|
|
366
|
+
.map(v => ({
|
|
367
|
+
name: v.name,
|
|
368
|
+
credential: v.credential,
|
|
369
|
+
seen_in: [...v.seen_in].slice(0, 5),
|
|
370
|
+
}))
|
|
371
|
+
.sort((a, b) => a.name.localeCompare(b.name)),
|
|
372
|
+
workspace_scoped_paths: findings.workspaceScopedPaths,
|
|
373
|
+
engines: findings.enginesRequirement,
|
|
374
|
+
os: findings.osRequirement,
|
|
375
|
+
cpu: findings.cpuRequirement,
|
|
376
|
+
readme_sections: readmeSections,
|
|
377
|
+
skills_available: skills,
|
|
378
|
+
tool_constraints: toolConstraints,
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
try {
|
|
382
|
+
fs.writeFileSync(path.join(pluginDir, REQUIREMENTS_FILE), JSON.stringify(shape, null, 2));
|
|
383
|
+
} catch { /* non-fatal; findings still returned to caller */ }
|
|
384
|
+
|
|
385
|
+
return shape;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Format a requirements report for terminal output. Compact, actionable,
|
|
390
|
+
* no color codes (caller adds ANSI wrappers around specific tokens).
|
|
391
|
+
* Returns an array of {level, text} lines: level ∈ 'info' | 'warn' | 'ok'.
|
|
392
|
+
*/
|
|
393
|
+
export function formatRequirementsReport(reqs, { verbose = false } = {}) {
|
|
394
|
+
const lines = [];
|
|
395
|
+
if (!reqs) return lines;
|
|
396
|
+
|
|
397
|
+
if (reqs.system_binaries?.length) {
|
|
398
|
+
lines.push({ level: 'warn', text: `system binaries required: ${reqs.system_binaries.map(b => b.name).join(', ')}` });
|
|
399
|
+
if (verbose) {
|
|
400
|
+
for (const b of reqs.system_binaries) {
|
|
401
|
+
const hint = b.install_hints?.darwin || b.install_hints?.linux;
|
|
402
|
+
lines.push({ level: 'info', text: ` ${b.name}${hint ? ` — install: ${hint}` : ''}` });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (reqs.env_vars?.length) {
|
|
407
|
+
const creds = reqs.env_vars.filter(v => v.credential);
|
|
408
|
+
const other = reqs.env_vars.filter(v => !v.credential);
|
|
409
|
+
if (creds.length) lines.push({ level: 'warn', text: `API keys / credentials: ${creds.map(v => v.name).join(', ')}` });
|
|
410
|
+
if (other.length && verbose) lines.push({ level: 'info', text: `other env vars: ${other.map(v => v.name).join(', ')}` });
|
|
411
|
+
}
|
|
412
|
+
if (reqs.workspace_scoped_paths) {
|
|
413
|
+
lines.push({ level: 'info', text: 'paths must be workspace-relative (relative to cwd)' });
|
|
414
|
+
}
|
|
415
|
+
if (reqs.readme_sections?.length) {
|
|
416
|
+
lines.push({ level: 'info', text: `README notes ${reqs.readme_sections.length} section(s): ${reqs.readme_sections.map(s => s.title).join(', ')}` });
|
|
417
|
+
}
|
|
418
|
+
if (reqs.skills_available?.length) {
|
|
419
|
+
lines.push({ level: 'info', text: `skills available: ${reqs.skills_available.join(', ')}` });
|
|
420
|
+
}
|
|
421
|
+
if (!lines.length) {
|
|
422
|
+
lines.push({ level: 'ok', text: 'no external requirements detected — pure JS/TS + npm deps' });
|
|
423
|
+
}
|
|
424
|
+
return lines;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* Check requirements against the host environment. Used by `plugin doctor`.
|
|
429
|
+
* Returns per-item results — caller prints and decides exit code.
|
|
430
|
+
*/
|
|
431
|
+
export function checkRequirementsAgainstHost(reqs) {
|
|
432
|
+
const results = { binaries: [], env_vars: [] };
|
|
433
|
+
|
|
434
|
+
const isWin = process.platform === 'win32';
|
|
435
|
+
const whichCmd = isWin ? 'where' : 'command -v';
|
|
436
|
+
for (const b of reqs?.system_binaries || []) {
|
|
437
|
+
let found = false, resolvedPath = null, version = null;
|
|
438
|
+
try {
|
|
439
|
+
const out = execSync(`${whichCmd} ${b.name}`, {
|
|
440
|
+
encoding: 'utf-8',
|
|
441
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
442
|
+
shell: isWin ? undefined : '/bin/sh',
|
|
443
|
+
}).trim();
|
|
444
|
+
resolvedPath = out.split(/\r?\n/)[0] || null;
|
|
445
|
+
found = Boolean(resolvedPath);
|
|
446
|
+
} catch { found = false; }
|
|
447
|
+
if (found) {
|
|
448
|
+
try {
|
|
449
|
+
version = execSync(`${b.name} --version`, {
|
|
450
|
+
encoding: 'utf-8',
|
|
451
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
452
|
+
}).split(/\r?\n/)[0].trim();
|
|
453
|
+
} catch { /* skip */ }
|
|
454
|
+
}
|
|
455
|
+
results.binaries.push({ name: b.name, found, path: resolvedPath, version, install_hints: b.install_hints });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
for (const v of reqs?.env_vars || []) {
|
|
459
|
+
const value = process.env[v.name];
|
|
460
|
+
results.env_vars.push({ name: v.name, credential: v.credential, set: Boolean(value && value.length) });
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return results;
|
|
464
|
+
}
|
|
465
|
+
|
|
@@ -24,20 +24,24 @@ import * as path from 'node:path';
|
|
|
24
24
|
import { COMPOSED_TOOL_SEPARATOR } from '../pi-compose.mjs';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Derive a pack slug from a
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* Derive a pack slug from a source package name. No forced suffix — a
|
|
28
|
+
* pack can be anything (studio, analyzer, connector, worker, …), and
|
|
29
|
+
* pinning a semantic to the slug guesses wrong most of the time. The
|
|
30
|
+
* default is the source name, sanitized. Author overrides with --slug.
|
|
31
|
+
*
|
|
32
|
+
* pi-web-access → pi-web-access
|
|
33
|
+
* pi-redmine → pi-redmine
|
|
34
|
+
* @ffmpeg/transitions → transitions (scope stripped)
|
|
35
|
+
* filesystem-mcp → filesystem-mcp
|
|
36
|
+
* plain-name → plain-name
|
|
32
37
|
*/
|
|
33
38
|
export function deriveSlug(packageName) {
|
|
34
39
|
let base = String(packageName || '').trim();
|
|
35
40
|
const scoped = base.match(/^@[^/]+\/(.+)$/);
|
|
36
41
|
if (scoped) base = scoped[1];
|
|
37
|
-
base = base.replace(/^pi-/, '');
|
|
38
42
|
base = base.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
|
|
39
|
-
if (!base) base = '
|
|
40
|
-
return
|
|
43
|
+
if (!base) base = 'pack';
|
|
44
|
+
return base;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
/**
|
|
@@ -77,13 +81,75 @@ function truncate(s, n) {
|
|
|
77
81
|
return str.slice(0, n - 1) + '…';
|
|
78
82
|
}
|
|
79
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Compose the "Requirements & constraints" block from the analyzer's
|
|
86
|
+
* findings. Injected into the generated agent's system prompt so the
|
|
87
|
+
* sub-agent knows what its composed tools need — no user teaching
|
|
88
|
+
* required. Falls back to an empty list if requirements is absent (fresh
|
|
89
|
+
* install where the analyzer didn't run, etc.).
|
|
90
|
+
*/
|
|
91
|
+
function requirementsPromptLines(requirements, namespace) {
|
|
92
|
+
if (!requirements) return [];
|
|
93
|
+
const lines = ['', 'Requirements & constraints (from ingredient analysis):'];
|
|
94
|
+
const bins = requirements.system_binaries || [];
|
|
95
|
+
if (bins.length) {
|
|
96
|
+
const names = bins.map(b => b.name).join(', ');
|
|
97
|
+
lines.push(
|
|
98
|
+
`- System binaries required: ${names}. If a tool errors with "ENOENT" or "spawn ${bins[0].name}", tell the user to install them (macOS: \`${bins[0].install_hints?.darwin || 'via brew'}\`; Linux: \`${bins[0].install_hints?.linux || 'via package manager'}\`).`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
const creds = (requirements.env_vars || []).filter(v => v.credential);
|
|
102
|
+
if (creds.length) {
|
|
103
|
+
lines.push(
|
|
104
|
+
`- API keys / credentials expected: ${creds.map(v => v.name).join(', ')}. If a tool fails with an auth error, ask the user to set the missing env var.`,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (requirements.workspace_scoped_paths) {
|
|
108
|
+
lines.push(
|
|
109
|
+
'- Paths passed to composed tools MUST be workspace-relative (relative to the current working directory). Absolute paths outside cwd are rejected with "Path is outside the workspace". If the user references an absolute path, ask them to `cd` closer to it or copy the file into the workspace.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
// Per-tool schema constraints the agent must respect. Emit BOTH required
|
|
113
|
+
// fields AND regex/range constraints — the underlying pi tools throw
|
|
114
|
+
// opaque path/type errors when a required param is missing, and the
|
|
115
|
+
// agent's default reasoning tends to skip params whose descriptions
|
|
116
|
+
// sound "optional" even when the schema marks them required.
|
|
117
|
+
const tc = requirements.tool_constraints || {};
|
|
118
|
+
const toolsWithConstraints = Object.keys(tc).filter(t => Object.keys(tc[t]).length);
|
|
119
|
+
if (toolsWithConstraints.length) {
|
|
120
|
+
lines.push('- Strict input schemas — supply EVERY required field and respect all constraints. Missing a required field usually throws an opaque error like `paths[1] argument must be of type string`:');
|
|
121
|
+
for (const t of toolsWithConstraints) {
|
|
122
|
+
const params = tc[t];
|
|
123
|
+
const required = Object.keys(params).filter(p => params[p].required);
|
|
124
|
+
const regexed = Object.entries(params).filter(([, c]) => c.regex);
|
|
125
|
+
const ranged = Object.entries(params).filter(([, c]) => c.min != null || c.max != null || c.enum);
|
|
126
|
+
if (required.length) {
|
|
127
|
+
lines.push(` - \`${namespace}${COMPOSED_TOOL_SEPARATOR}${t}\` requires: ${required.map(p => `\`${p}\``).join(', ')}`);
|
|
128
|
+
}
|
|
129
|
+
for (const [param, c] of regexed) {
|
|
130
|
+
lines.push(` · \`${param}\` must match \`${c.regex}\``);
|
|
131
|
+
}
|
|
132
|
+
for (const [param, c] of ranged) {
|
|
133
|
+
const parts = [];
|
|
134
|
+
if (c.min != null) parts.push(`min ${c.min}`);
|
|
135
|
+
if (c.max != null) parts.push(`max ${c.max}`);
|
|
136
|
+
if (c.enum) parts.push(`one of ${JSON.stringify(c.enum)}`);
|
|
137
|
+
lines.push(` · \`${param}\` ${parts.join(', ')}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// If we detected no external requirements at all, keep the block out so
|
|
142
|
+
// the prompt stays clean.
|
|
143
|
+
return lines.length > 1 ? lines : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
80
146
|
/**
|
|
81
147
|
* Compose an agent system prompt from the pi package + its tools.
|
|
82
148
|
* Focused on WHAT the agent should do, not step-by-step recipes — the
|
|
83
149
|
* generic template can't know the pack's domain. Users are expected to
|
|
84
150
|
* edit the prompt after generation.
|
|
85
151
|
*/
|
|
86
|
-
function generatePrompt(packageName, namespace, toolNames, hasState) {
|
|
152
|
+
function generatePrompt(packageName, namespace, toolNames, hasState, requirements = null) {
|
|
87
153
|
const composed = toolNames.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`);
|
|
88
154
|
const stateLines = hasState
|
|
89
155
|
? [
|
|
@@ -103,6 +169,7 @@ function generatePrompt(packageName, namespace, toolNames, hasState) {
|
|
|
103
169
|
'Available composed tools:',
|
|
104
170
|
...composed.map(t => `- \`${t}\``),
|
|
105
171
|
...stateLines,
|
|
172
|
+
...requirementsPromptLines(requirements, namespace),
|
|
106
173
|
'',
|
|
107
174
|
'Rules:',
|
|
108
175
|
'- Use the composed tools directly — do not describe what you would do, DO it.',
|
|
@@ -453,7 +520,16 @@ export function scaffoldPiPack({
|
|
|
453
520
|
`Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
|
|
454
521
|
240,
|
|
455
522
|
);
|
|
456
|
-
|
|
523
|
+
|
|
524
|
+
// Pull the requirements sidecar the analyzer wrote at install time
|
|
525
|
+
// (may be absent if user is scaffolding manually with an older ingredient).
|
|
526
|
+
let requirements = null;
|
|
527
|
+
const reqSidecar = path.join(piDir, '.bahulam-requirements.json');
|
|
528
|
+
if (fs.existsSync(reqSidecar)) {
|
|
529
|
+
try { requirements = JSON.parse(fs.readFileSync(reqSidecar, 'utf-8')); } catch { /* skip */ }
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const systemPrompt = generatePrompt(packageName, namespace, toolNames, state, requirements);
|
|
457
533
|
|
|
458
534
|
const manifest = renderManifest({
|
|
459
535
|
slug,
|
|
@@ -130,5 +130,33 @@ export function createPiShim({ pluginName = 'pi', captured }) {
|
|
|
130
130
|
},
|
|
131
131
|
};
|
|
132
132
|
|
|
133
|
-
|
|
133
|
+
// Pi's ExtensionAPI is a moving target — packages call methods we haven't
|
|
134
|
+
// stubbed yet (registerMessageRenderer, registerRoute, registerHandler,
|
|
135
|
+
// …). Any unstubbed method call throws, aborting activation before
|
|
136
|
+
// registerTool ever runs, and the probe reports 0 tools.
|
|
137
|
+
//
|
|
138
|
+
// Fall back to a no-op returner for every unknown property so activation
|
|
139
|
+
// reaches its full extent. A tool's runtime call may still fail if it
|
|
140
|
+
// needed that surface — that's an accurate signal at execution time,
|
|
141
|
+
// not a silent black hole at load time.
|
|
142
|
+
return new Proxy(pi, {
|
|
143
|
+
get(target, prop, receiver) {
|
|
144
|
+
if (prop in target) return Reflect.get(target, prop, receiver);
|
|
145
|
+
if (typeof prop === 'symbol') return undefined;
|
|
146
|
+
if (process.env.DEBUG) {
|
|
147
|
+
process.stderr.write(`[pi:${pluginName}] shim: pi.${String(prop)} stubbed (no-op)\n`);
|
|
148
|
+
}
|
|
149
|
+
// Return a callable that also has method access (e.g. pi.foo.bar).
|
|
150
|
+
// Property access on the stub returns another stub, so chains never
|
|
151
|
+
// throw. Result is undefined so anything that reads a return value
|
|
152
|
+
// treats it as "not present" (typeof result === 'undefined').
|
|
153
|
+
const stub = function stub() { return undefined; };
|
|
154
|
+
return new Proxy(stub, {
|
|
155
|
+
get(t, p) {
|
|
156
|
+
if (typeof p === 'symbol') return t[p];
|
|
157
|
+
return stub;
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
},
|
|
161
|
+
});
|
|
134
162
|
}
|
|
@@ -39,6 +39,8 @@ export const RESERVED_TOOL_NAMES = new Set([
|
|
|
39
39
|
// write
|
|
40
40
|
'write_file', 'write_project', 'edit_file', 'delete_file', 'shell',
|
|
41
41
|
'analyze_image', 'generate_image',
|
|
42
|
+
// background jobs (PRD-102 §6.2.3) — long-running renders, builds, etc.
|
|
43
|
+
'job_output', 'job_kill', 'job_status', 'job_list',
|
|
42
44
|
// agent/skill/workflow admin
|
|
43
45
|
'ask_user', 'agent_create', 'agent_sync', 'agents_list',
|
|
44
46
|
'skill_install', 'skill_update', 'skill_remove', 'skill_view', 'skills_list',
|
package/src/terminal/main.mjs
CHANGED
|
@@ -27,6 +27,7 @@ const PLUGIN_MANAGEMENT_COMMANDS = new Set([
|
|
|
27
27
|
'validate', 'check', 'lint',
|
|
28
28
|
'list', 'ls', 'remove', 'rm', 'uninstall',
|
|
29
29
|
'enable', 'disable', 'info', 'update', 'upgrade',
|
|
30
|
+
'doctor',
|
|
30
31
|
]);
|
|
31
32
|
|
|
32
33
|
function parsePluginArgs(argv) {
|
|
@@ -69,7 +70,7 @@ function parsePluginArgs(argv) {
|
|
|
69
70
|
if (arg && (arg.includes('/') || fs.existsSync?.(arg))) parsed.source = arg;
|
|
70
71
|
else parsed.pluginName = arg;
|
|
71
72
|
}
|
|
72
|
-
else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade'].includes(parsed.action)) {
|
|
73
|
+
else if (['info', 'remove', 'rm', 'uninstall', 'enable', 'disable', 'update', 'upgrade', 'doctor'].includes(parsed.action)) {
|
|
73
74
|
parsed.pluginName = positional.shift() || null;
|
|
74
75
|
}
|
|
75
76
|
} else {
|
|
@@ -296,7 +297,7 @@ async function main() {
|
|
|
296
297
|
const verb = subcommandArgs[0];
|
|
297
298
|
const rest = subcommandArgs.slice(1);
|
|
298
299
|
process.stderr.write(`\x1b[33m!\x1b[0m \`bahulam plugin ${verb}\` moved to top-level. Use:\n`);
|
|
299
|
-
process.stderr.write(` \x1b[36mbahulam install ${rest.join(' ')}\x1b[0m (pack — scaffolds around pi:,
|
|
300
|
+
process.stderr.write(` \x1b[36mbahulam install ${rest.join(' ')}\x1b[0m (pack — registry, scaffolds around pi:, git/tarball/local)\n`);
|
|
300
301
|
process.stderr.write(` \x1b[36mbahulam pull ${rest.join(' ')}\x1b[0m (ingredient only — pi: sources)\n`);
|
|
301
302
|
process.exit(2);
|
|
302
303
|
}
|
|
@@ -355,6 +356,8 @@ async function main() {
|
|
|
355
356
|
\x1b[1mPacks & ingredients:\x1b[0m
|
|
356
357
|
bahulam pull pi:<name> Pull a pi ingredient (composable, not runnable on its own)
|
|
357
358
|
bahulam install pi:<name> Pull ingredient + scaffold a full Bahulam pack around it
|
|
359
|
+
bahulam install <name> Install from awesome-bahulam-plugins
|
|
360
|
+
bahulam install bahulam:<name> Explicit awesome-bahulam-plugins lookup
|
|
358
361
|
bahulam install <git-url> Install a hand-authored pack from git
|
|
359
362
|
bahulam install <local-path> Install a hand-authored pack from disk
|
|
360
363
|
bahulam plugin list List installed packs and pi ingredients
|