@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.
@@ -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
+