@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,45 @@
1
+ /**
2
+ * Node ESM loader hook that intercepts `import { pi } from 'pi'` and
3
+ * resolves it to a virtual module which re-exports our shim.
4
+ *
5
+ * Registered from probe.mjs via child_process spawn with:
6
+ * node --import ./loader-hook.mjs -e '<probe script>'
7
+ *
8
+ * The virtual module source is generated at load time so it can inline
9
+ * the shim import URL (avoids brittle relative paths across cwd's).
10
+ */
11
+
12
+ import { pathToFileURL } from 'node:url';
13
+ import { register } from 'node:module';
14
+ import * as path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const SHIM_URL = pathToFileURL(path.join(HERE, 'shim.mjs')).href;
19
+ const VIRTUAL_URL = 'bahulam-pi-shim:v1';
20
+
21
+ // Register ourselves as a loader — this file is `--import`ed, and the
22
+ // register() call installs the resolve/load hooks below into a worker
23
+ // data URL. Simpler than a standalone hooks file.
24
+ register(`data:text/javascript,${encodeURIComponent(`
25
+ export function resolve(specifier, context, nextResolve) {
26
+ if (specifier === 'pi') return { shortCircuit: true, url: '${VIRTUAL_URL}' };
27
+ return nextResolve(specifier, context);
28
+ }
29
+ export function load(url, context, nextLoad) {
30
+ if (url === '${VIRTUAL_URL}') {
31
+ return {
32
+ shortCircuit: true,
33
+ format: 'module',
34
+ source: \`
35
+ import { createPiShim } from ${JSON.stringify(SHIM_URL)};
36
+ const captured = globalThis.__bahulam_pi_captured ||= { tools: [], commands: [] };
37
+ const pluginName = process.env.BAHULAM_PI_PLUGIN || 'pi';
38
+ export const pi = createPiShim({ pluginName, captured });
39
+ export default pi;
40
+ \`,
41
+ };
42
+ }
43
+ return nextLoad(url, context);
44
+ }
45
+ `)}`, import.meta.url);
@@ -0,0 +1,294 @@
1
+ /**
2
+ * Probe-load a pi package in an isolated child process to discover what
3
+ * tools/commands it registers. Result is cached to <plugin-dir>/.bahulam-tools.json
4
+ * so subsequent invocations skip the probe.
5
+ *
6
+ * Isolation matters: pi extensions can throw at load, do side effects,
7
+ * or use require-in-ESM. We don't want any of that leaking into the CLI
8
+ * process. The child does the load, prints the capture JSON, exits.
9
+ */
10
+
11
+ import { spawn } from 'node:child_process';
12
+ import * as fs from 'node:fs';
13
+ import * as path from 'node:path';
14
+ import { fileURLToPath, pathToFileURL } from 'node:url';
15
+ import { PI_TOOLS_CACHE } from '../pi-compose.mjs';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const LOADER_HOOK = path.join(HERE, 'loader-hook.mjs');
19
+ const PROBE_TIMEOUT_MS = 15_000;
20
+
21
+ /**
22
+ * Resolve the pi package's entry point. Prefer `package.json.main`, fall
23
+ * back to conventional locations. A pi package may set `pi.main` — we
24
+ * respect that first.
25
+ */
26
+ function resolvePiEntry(pluginDir) {
27
+ const pkgPath = path.join(pluginDir, 'package.json');
28
+ if (!fs.existsSync(pkgPath)) {
29
+ throw new Error(`Pi package missing package.json: ${pluginDir}`);
30
+ }
31
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
32
+ // Pi's real convention (per pi-web-access and others): `pkg.pi.extensions`
33
+ // is an array of files OR directories. A directory means "scan for
34
+ // *.ts/.js/.mjs entries." A file is loaded directly.
35
+ const piExts = Array.isArray(pkg.pi?.extensions) ? pkg.pi.extensions : [];
36
+ const expandedExts = [];
37
+ for (const rel of piExts) {
38
+ const abs = path.resolve(pluginDir, rel);
39
+ if (!fs.existsSync(abs)) continue;
40
+ const stat = fs.statSync(abs);
41
+ if (stat.isFile()) { expandedExts.push(abs); continue; }
42
+ if (stat.isDirectory()) {
43
+ for (const entry of fs.readdirSync(abs)) {
44
+ if (/\.(ts|mjs|js|cjs)$/.test(entry)) expandedExts.push(path.join(abs, entry));
45
+ }
46
+ }
47
+ }
48
+ if (expandedExts.length) return expandedExts;
49
+
50
+ // Fallback: main / exports / conventional entries
51
+ const candidates = [
52
+ pkg.pi?.main,
53
+ pkg.exports?.['.']?.import,
54
+ pkg.exports?.['.']?.default,
55
+ pkg.main,
56
+ 'index.mjs', 'index.js', 'dist/index.mjs', 'dist/index.js',
57
+ ].filter(Boolean);
58
+ for (const rel of candidates) {
59
+ const abs = path.resolve(pluginDir, rel);
60
+ if (fs.existsSync(abs)) return [abs];
61
+ }
62
+ throw new Error(`Pi package has no discoverable entry point (tried ${candidates.join(', ')}): ${pluginDir}`);
63
+ }
64
+
65
+ /**
66
+ * Run the pi package in a child process, capture registered tools/commands.
67
+ * Returns { tools: [{name, schema}], commands: [{cmd}] } — handler
68
+ * functions are intentionally NOT included (they don't serialize).
69
+ * Handlers get re-imported per-invocation at execution time (Step 6).
70
+ */
71
+ export async function probePiExtension(pluginDir, { pluginName } = {}) {
72
+ const entries = resolvePiEntry(pluginDir);
73
+ const entryUrls = entries.map(e => pathToFileURL(e).href);
74
+ const name = pluginName || path.basename(pluginDir);
75
+
76
+ // Pi's canonical extension pattern: `export default function (pi) { pi.registerTool(...) }`.
77
+ // Some packages split extensions across multiple files (pkg.pi.extensions
78
+ // as an array or a directory). We import each in order, invoke its
79
+ // default export with our shim, aggregate the captures.
80
+ const script = `
81
+ globalThis.__bahulam_pi_captured = { tools: [], commands: [] };
82
+ (async () => {
83
+ const entries = ${JSON.stringify(entryUrls)};
84
+ const { pi } = await import('pi');
85
+ const warnings = [];
86
+ for (const url of entries) {
87
+ try {
88
+ const mod = await import(url);
89
+ const activate = typeof mod?.default === 'function' ? mod.default
90
+ : typeof mod?.activate === 'function' ? mod.activate
91
+ : null;
92
+ if (activate) {
93
+ try { await activate(pi); }
94
+ catch (err) { warnings.push({ entry: url, message: String(err && err.message || err) }); }
95
+ }
96
+ } catch (err) {
97
+ warnings.push({ entry: url, message: String(err && err.message || err) });
98
+ }
99
+ }
100
+ const out = {
101
+ tools: globalThis.__bahulam_pi_captured.tools.map(t => ({
102
+ name: t.name,
103
+ description: t.description || (t.schema && t.schema.description) || '',
104
+ input_schema: t.schema && typeof t.schema === 'object'
105
+ ? (t.schema.parameters || t.schema.input_schema || t.schema)
106
+ : { type: 'object', properties: {} },
107
+ })),
108
+ commands: globalThis.__bahulam_pi_captured.commands.map(c => ({ command: c.cmd, description: c.description || '' })),
109
+ warnings,
110
+ };
111
+ process.stdout.write(JSON.stringify(out));
112
+ })().catch(err => {
113
+ process.stderr.write(JSON.stringify({ probe_error: String(err && err.message || err) }));
114
+ process.exit(1);
115
+ });
116
+ `;
117
+
118
+ return await new Promise((resolve, reject) => {
119
+ const child = spawn(process.execPath, ['--import', LOADER_HOOK, '-e', script], {
120
+ env: { ...process.env, BAHULAM_PI_PLUGIN: name },
121
+ stdio: ['ignore', 'pipe', 'pipe'],
122
+ });
123
+ let stdout = '', stderr = '';
124
+ child.stdout.on('data', d => stdout += d);
125
+ child.stderr.on('data', d => stderr += d);
126
+ const timer = setTimeout(() => {
127
+ child.kill('SIGKILL');
128
+ reject(new Error(`pi probe timed out after ${PROBE_TIMEOUT_MS}ms: ${pluginDir}`));
129
+ }, PROBE_TIMEOUT_MS);
130
+ child.on('close', code => {
131
+ clearTimeout(timer);
132
+ if (code !== 0) {
133
+ const detail = stderr.trim() || `exit ${code}`;
134
+ return reject(new Error(`pi probe failed: ${detail}`));
135
+ }
136
+ try {
137
+ resolve(JSON.parse(stdout));
138
+ } catch (err) {
139
+ reject(new Error(`pi probe returned invalid JSON: ${err.message}`));
140
+ }
141
+ });
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Discover-or-cache: reads .bahulam-tools.json if present; probes and
147
+ * writes it if not. Callers get a stable JSON shape either way.
148
+ */
149
+ export async function discoverPiTools(pluginDir, opts = {}) {
150
+ const cachePath = path.join(pluginDir, PI_TOOLS_CACHE);
151
+ if (fs.existsSync(cachePath) && !opts.force) {
152
+ try {
153
+ return JSON.parse(fs.readFileSync(cachePath, 'utf-8'));
154
+ } catch { /* fall through to probe */ }
155
+ }
156
+ const captured = await probePiExtension(pluginDir, opts);
157
+ const shape = {
158
+ tools: captured.tools,
159
+ commands: captured.commands,
160
+ discovered_at: new Date().toISOString(),
161
+ };
162
+ fs.writeFileSync(cachePath, JSON.stringify(shape, null, 2));
163
+ return shape;
164
+ }
165
+
166
+ /**
167
+ * Load a specific pi tool handler for execution. Re-imports the pi
168
+ * extension entry through the shim, extracts the named tool's handler.
169
+ * Simpler than a persistent child — ~50ms overhead per call, fine for
170
+ * seconds-scale tool invocations. Persistent child is a Sprint 2 profile
171
+ * decision.
172
+ */
173
+ export async function loadPiToolHandler(pluginDir, toolName, { pluginName } = {}) {
174
+ const entries = resolvePiEntry(pluginDir);
175
+ const entryUrls = entries.map(e => pathToFileURL(e).href);
176
+ const name = pluginName || path.basename(pluginDir);
177
+
178
+ // Same activation dance as probePiExtension: import the module, call
179
+ // its default export with our shim to trigger registrations, then look
180
+ // up the named tool. Pi's canonical tool signature is
181
+ // `execute(id, params)` where id is a call identifier — we generate a
182
+ // synthetic one since our loop doesn't have a native concept for it.
183
+ const script = `
184
+ globalThis.__bahulam_pi_captured = { tools: [], commands: [] };
185
+ (async () => {
186
+ try {
187
+ const { pi } = await import('pi');
188
+ for (const url of ${JSON.stringify(entryUrls)}) {
189
+ const mod = await import(url);
190
+ const activate = typeof mod?.default === 'function' ? mod.default
191
+ : typeof mod?.activate === 'function' ? mod.activate
192
+ : null;
193
+ if (activate) { try { await activate(pi); } catch (_) { /* one bad extension shouldn't block others */ } }
194
+ }
195
+ const target = globalThis.__bahulam_pi_captured.tools.find(t => t.name === ${JSON.stringify(toolName)});
196
+ if (!target || typeof target.handler !== 'function') {
197
+ process.stdout.write(JSON.stringify({ ok: false, error: 'tool not found or no handler: ' + ${JSON.stringify(toolName)} }));
198
+ process.exit(0);
199
+ }
200
+ const args = JSON.parse(process.env.BAHULAM_PI_ARGS || '{}');
201
+ const callId = 'bahulam-' + Date.now().toString(36);
202
+ // Pi's descriptor-form tool signature is FIVE positional args:
203
+ // execute(callId, params, signal, onUpdate, ctx)
204
+ // Passing fewer causes ctx to arrive as one of the earlier slots
205
+ // (or undefined), which trips interactive-workflow paths that
206
+ // then error with "Missing extension context." Match the signature
207
+ // exactly, with a no-op signal + onUpdate for the non-interactive
208
+ // headless case:
209
+ // signal — AbortSignal from a fresh controller (never fires)
210
+ // onUpdate — event callback; discard in headless mode
211
+ // ctx — extension context:
212
+ // hasUI: false → tools resolve non-interactive workflows
213
+ // cwd → project working directory
214
+ // isProjectTrusted:false → conservative; shell-exec-style tools refuse
215
+ // model/modelRegistry → null; model-dependent tools fail gracefully
216
+ // ui: null → no UI adapter
217
+ const controller = new AbortController();
218
+ const ctx = {
219
+ hasUI: false,
220
+ cwd: process.cwd(),
221
+ isProjectTrusted: false,
222
+ model: null,
223
+ modelRegistry: null,
224
+ ui: null,
225
+ };
226
+ const onUpdate = () => {}; // pi may emit progress events; no-op them
227
+ // Shim tags each capture with _form. Descriptor form invokes as
228
+ // execute(callId, params, signal, onUpdate, ctx); positional legacy
229
+ // form invokes as handler(args).
230
+ const result = target._form === 'positional'
231
+ ? await target.handler(args)
232
+ : await target.handler(callId, args, controller.signal, onUpdate, ctx);
233
+ const payload = typeof result === 'string'
234
+ ? { output: result }
235
+ : (result && typeof result === 'object' ? result : { output: String(result) });
236
+ process.stdout.write(JSON.stringify({ ok: true, result: payload }));
237
+ } catch (err) {
238
+ process.stdout.write(JSON.stringify({ ok: false, error: String(err && err.message || err) }));
239
+ }
240
+ })();
241
+ `;
242
+
243
+ return async function invoke(args) {
244
+ return await new Promise((resolve) => {
245
+ const child = spawn(process.execPath, ['--import', LOADER_HOOK, '-e', script], {
246
+ env: {
247
+ ...process.env,
248
+ BAHULAM_PI_PLUGIN: name,
249
+ BAHULAM_PI_ARGS: JSON.stringify(args || {}),
250
+ },
251
+ stdio: ['ignore', 'pipe', 'pipe'],
252
+ });
253
+ let stdout = '', stderr = '';
254
+ child.stdout.on('data', d => stdout += d);
255
+ child.stderr.on('data', d => stderr += d);
256
+ const timer = setTimeout(() => {
257
+ child.kill('SIGKILL');
258
+ resolve({ success: false, output: `pi tool timed out: ${toolName}` });
259
+ }, 60_000);
260
+ child.on('close', () => {
261
+ clearTimeout(timer);
262
+ try {
263
+ const parsed = JSON.parse(stdout);
264
+ if (parsed.ok) {
265
+ const raw = parsed.result;
266
+ // Pi tools return an object shaped like MCP responses:
267
+ // { content: [{type:'text', text}], details?: { error? } }
268
+ // Detect the details.error case so we don't dress up a
269
+ // failure as success. The agent can then react to the
270
+ // real error string instead of treating it as content.
271
+ const detailsErr = raw && typeof raw === 'object' && raw.details && raw.details.error;
272
+ if (detailsErr) {
273
+ const txt = Array.isArray(raw.content)
274
+ ? raw.content.map(c => c?.text || '').filter(Boolean).join('\n').trim()
275
+ : '';
276
+ return resolve({ success: false, output: txt || String(detailsErr) });
277
+ }
278
+ // Prefer explicit output, then flatten MCP-style content, then
279
+ // fall back to the raw payload for pi tools that return a
280
+ // simple {output: '...'}.
281
+ let output = raw?.output;
282
+ if (!output && Array.isArray(raw?.content)) {
283
+ output = raw.content.map(c => c?.text || '').filter(Boolean).join('\n').trim();
284
+ }
285
+ return resolve({ success: true, output: output ?? raw });
286
+ }
287
+ return resolve({ success: false, output: parsed.error || stderr || 'pi tool failed' });
288
+ } catch {
289
+ resolve({ success: false, output: stderr || 'pi tool returned invalid JSON' });
290
+ }
291
+ });
292
+ });
293
+ };
294
+ }