@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,563 @@
1
+ /**
2
+ * scaffoldPiPack — generate a full Bahulam pack directory from an
3
+ * installed pi ingredient. The generated pack composes the pi package's
4
+ * tools, adds a persistent state layer (append-only records), a single
5
+ * agent that has both toolsets, and a workspace panel that reads the
6
+ * state live via SSE.
7
+ *
8
+ * The pack is written into ~/.bahulam/plugins/<slug>/ (or the target
9
+ * dir the caller provides) so `bahulam install pi:<pkg>` is one command
10
+ * and the pack is immediately installed + preflight-approved.
11
+ *
12
+ * Design tenets:
13
+ * - Zero authoring cost. All content templated from the pi tools cache.
14
+ * - Composed pi tools ARE the surface — no need to wrap each one.
15
+ * - Native tools are limited to the state layer (save/list/drop item).
16
+ * This bridges the pi surface (stateless) to the workspace panel.
17
+ * - Everything is overridable. The user can edit the generated files
18
+ * after the fact — they live in ~/.bahulam/plugins/<slug>/ and won't
19
+ * be regenerated on re-install.
20
+ */
21
+
22
+ import * as fs from 'node:fs';
23
+ import * as path from 'node:path';
24
+ import { COMPOSED_TOOL_SEPARATOR } from '../pi-compose.mjs';
25
+
26
+ /**
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
37
+ */
38
+ export function deriveSlug(packageName) {
39
+ let base = String(packageName || '').trim();
40
+ const scoped = base.match(/^@[^/]+\/(.+)$/);
41
+ if (scoped) base = scoped[1];
42
+ base = base.replace(/[^a-z0-9-]+/gi, '-').replace(/^-+|-+$/g, '').toLowerCase();
43
+ if (!base) base = 'pack';
44
+ return base;
45
+ }
46
+
47
+ /**
48
+ * Derive a short namespace prefix from the pi package name. Used as
49
+ * `as:` in the composes block. Kept short so composed tool names stay
50
+ * readable: `web__web_search`, `fx__add_transitions` (double underscore
51
+ * matches Claude Code / MCP naming and passes Anthropic's tool-name regex).
52
+ */
53
+ export function deriveNamespace(packageName) {
54
+ let base = String(packageName || '').trim();
55
+ const scoped = base.match(/^@([^/]+)\/(.+)$/);
56
+ if (scoped) base = scoped[2];
57
+ base = base.replace(/^pi-/, '');
58
+ const first = base.split(/[-_.]/)[0] || 'pi';
59
+ return first.slice(0, 8).toLowerCase();
60
+ }
61
+
62
+ function yamlString(s) {
63
+ const str = String(s || '');
64
+ if (str === '' || /[:#{}\[\],&*!|>'"%@`\n]/.test(str)) {
65
+ return JSON.stringify(str);
66
+ }
67
+ return str;
68
+ }
69
+
70
+ function yamlBlock(text, indent) {
71
+ const pad = ' '.repeat(indent);
72
+ const clean = String(text || '').trim().replace(/\r\n/g, '\n');
73
+ if (!clean) return '""';
74
+ const lines = clean.split('\n').map(l => l.trimEnd());
75
+ return '|\n' + lines.map(l => pad + l).join('\n');
76
+ }
77
+
78
+ function truncate(s, n) {
79
+ const str = String(s || '').replace(/\s+/g, ' ').trim();
80
+ if (str.length <= n) return str;
81
+ return str.slice(0, n - 1) + '…';
82
+ }
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
+
146
+ /**
147
+ * Compose an agent system prompt from the pi package + its tools.
148
+ * Focused on WHAT the agent should do, not step-by-step recipes — the
149
+ * generic template can't know the pack's domain. Users are expected to
150
+ * edit the prompt after generation.
151
+ */
152
+ function generatePrompt(packageName, namespace, toolNames, hasState, requirements = null) {
153
+ const composed = toolNames.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`);
154
+ const stateLines = hasState
155
+ ? [
156
+ '',
157
+ 'Persistence:',
158
+ '- Call `list_items` first when the user references prior work — the notebook',
159
+ ' survives across turns and populates the workspace panel.',
160
+ '- After any meaningful tool call, `save_item` with a title, source, and short note',
161
+ ' so the finding is durable and visible in the panel.',
162
+ '- `drop_item` removes an entry by id.',
163
+ ]
164
+ : [];
165
+ const lines = [
166
+ `You are the ${packageName} specialist.`,
167
+ `You have direct access to ${toolNames.length} composed tool${toolNames.length === 1 ? '' : 's'} from the pi package \`${packageName}\`.`,
168
+ '',
169
+ 'Available composed tools:',
170
+ ...composed.map(t => `- \`${t}\``),
171
+ ...stateLines,
172
+ ...requirementsPromptLines(requirements, namespace),
173
+ '',
174
+ 'Rules:',
175
+ '- Use the composed tools directly — do not describe what you would do, DO it.',
176
+ '- If a tool fails, report the exact error message. Do NOT fall back to general',
177
+ ' knowledge for tasks the tool was meant to answer.',
178
+ '- Cite sources or IDs from tool responses whenever you make a claim.',
179
+ ];
180
+ return lines.join('\n');
181
+ }
182
+
183
+ /**
184
+ * Emit plugin.yaml as a hand-crafted string. YAML libraries add noise
185
+ * (quoted keys, over-escaping); a small emitter here yields a diff-
186
+ * friendly manifest the user can edit.
187
+ */
188
+ function renderManifest({ slug, packageName, versionRange, namespace, exposeTools, agentSlug, agentDescription, hasState, hasWorkspace, systemPrompt }) {
189
+ const versionSpec = versionRange ? `${packageName}@${versionRange}` : packageName;
190
+ const tools = hasState ? [
191
+ ' tools:',
192
+ ' - name: save_item',
193
+ ' description: >',
194
+ ` Persist an item to the ${slug} notebook — a title, an optional source URL,`,
195
+ ' and freeform notes. The workspace panel and future turns see it immediately.',
196
+ ` tool: ./tools/save-item.mjs`,
197
+ ' parameters:',
198
+ ' type: object',
199
+ ' properties:',
200
+ ' title: { type: string, description: "Short headline for the item" }',
201
+ ' source: { type: string, description: "Source URL or identifier (optional)" }',
202
+ ' notes: { type: string, description: "Freeform notes (optional)" }',
203
+ ' topic: { type: string, description: "Topic tag for filtering (optional)" }',
204
+ ' required: [title]',
205
+ '',
206
+ ' - name: list_items',
207
+ ' description: >',
208
+ ' Read persisted items from the notebook. Use this first when the user',
209
+ ' references prior work, before re-running composed tools.',
210
+ ` tool: ./tools/list-items.mjs`,
211
+ ' parameters:',
212
+ ' type: object',
213
+ ' properties:',
214
+ ' topic: { type: string, description: "Filter by topic tag (exact match). Omit for all." }',
215
+ ' limit: { type: integer, description: "Max rows, default 50" }',
216
+ '',
217
+ ' - name: drop_item',
218
+ ' description: Remove a persisted item by its id.',
219
+ ` tool: ./tools/drop-item.mjs`,
220
+ ' parameters:',
221
+ ' type: object',
222
+ ' properties:',
223
+ ' id: { type: integer, description: "Item id (from list_items)" }',
224
+ ' required: [id]',
225
+ '',
226
+ ] : [' tools: []', ''];
227
+
228
+ const composesBlock = [
229
+ ' composes:',
230
+ ` - source: pi:${versionSpec}`,
231
+ ` as: ${namespace}`,
232
+ ' expose:',
233
+ ...exposeTools.map(t => ` - ${t}`),
234
+ ' verified: true',
235
+ '',
236
+ ];
237
+
238
+ const agentToolRefs = [
239
+ ...(hasState ? ['save_item', 'list_items', 'drop_item'] : []),
240
+ ...exposeTools.map(t => `${namespace}${COMPOSED_TOOL_SEPARATOR}${t}`),
241
+ ];
242
+
243
+ const agentBlock = [
244
+ ' agents:',
245
+ ` - slug: ${agentSlug}`,
246
+ ` name: ${yamlString(agentSlug.replace(/-/g, ' '))}`,
247
+ ' role: specialist',
248
+ ' description: >',
249
+ ` ${agentDescription}`,
250
+ ' tools:',
251
+ ...agentToolRefs.map(t => ` - ${t}`),
252
+ ` system_prompt: ${yamlBlock(systemPrompt, 8)}`,
253
+ '',
254
+ ];
255
+
256
+ const workspaceBlock = hasWorkspace ? [
257
+ ' workspace:',
258
+ ' views:',
259
+ ' - type: panel',
260
+ ` name: ${yamlString(slug.replace(/-/g, ' '))}`,
261
+ ' source: ./workspace/panel.html',
262
+ '',
263
+ ] : [];
264
+
265
+ return [
266
+ 'apiVersion: bahulam.plugin/1',
267
+ 'kind: Plugin',
268
+ 'metadata:',
269
+ ` name: ${slug}`,
270
+ ' version: 0.1.0',
271
+ ' description: >',
272
+ ` Auto-scaffolded pack composing pi:${packageName}.`,
273
+ ` Edit tools/, workspace/, and this manifest to customize.`,
274
+ '',
275
+ 'spec:',
276
+ ...tools,
277
+ ...composesBlock,
278
+ ...agentBlock,
279
+ ...workspaceBlock,
280
+ ].join('\n');
281
+ }
282
+
283
+ const SAVE_ITEM_TOOL = `/**
284
+ * save_item — persist a single item to the pack's notebook.
285
+ * Append-style records so a topic can accumulate many entries over time.
286
+ * The workspace panel binds to this stream.
287
+ */
288
+ export async function call(args = {}, options = {}) {
289
+ const title = String(args.title || '').trim();
290
+ if (!title) return { success: false, output: 'title is required' };
291
+
292
+ const state = options.state ? await options.state : null;
293
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
294
+
295
+ const item = {
296
+ title,
297
+ source: String(args.source || '').trim(),
298
+ notes: String(args.notes || '').slice(0, 1000),
299
+ topic: String(args.topic || '').trim(),
300
+ at: new Date().toISOString(),
301
+ };
302
+ const record = state.append('items', item);
303
+ return {
304
+ success: true,
305
+ output: \`Saved item #\${record?.id || ''}: \${title}\`,
306
+ item: { ...item, id: record?.id },
307
+ };
308
+ }
309
+ `;
310
+
311
+ const LIST_ITEMS_TOOL = `/**
312
+ * list_items — read persisted items from the pack notebook, most recent first.
313
+ */
314
+ export async function call(args = {}, options = {}) {
315
+ const state = options.state ? await options.state : null;
316
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
317
+
318
+ const topic = String(args.topic || '').trim().toLowerCase();
319
+ const limit = Math.max(1, Math.min(500, Number(args.limit) || 50));
320
+ const rows = state.list('items', { limit, order: 'desc' }) || [];
321
+ const filtered = topic
322
+ ? rows.filter(r => String(r.payload?.topic || '').toLowerCase() === topic)
323
+ : rows;
324
+ const summary = filtered.map(r => \`#\${r.id} · \${r.payload?.title || ''}\${r.payload?.topic ? ' (' + r.payload.topic + ')' : ''}\`).join('\\n');
325
+ return {
326
+ success: true,
327
+ output: filtered.length ? summary : (topic ? \`No items for topic '\${topic}'\` : 'No items yet'),
328
+ items: filtered.map(r => ({ id: r.id, ...(r.payload || {}), created_at: r.created_at })),
329
+ };
330
+ }
331
+ `;
332
+
333
+ const DROP_ITEM_TOOL = `/**
334
+ * drop_item — remove a persisted item by id.
335
+ */
336
+ export async function call(args = {}, options = {}) {
337
+ const id = Number(args.id);
338
+ if (!Number.isInteger(id) || id <= 0) {
339
+ return { success: false, output: 'id must be a positive integer' };
340
+ }
341
+ const state = options.state ? await options.state : null;
342
+ if (!state) return { success: false, output: 'Shared blackboard unavailable' };
343
+
344
+ const info = state.db.prepare('DELETE FROM records WHERE stream = ? AND id = ?').run('items', id);
345
+ return {
346
+ success: info.changes > 0,
347
+ output: info.changes > 0 ? \`Dropped item #\${id}\` : \`No item with id \${id}\`,
348
+ };
349
+ }
350
+ `;
351
+
352
+ function renderPanel(slug, packageName, composedToolNames) {
353
+ const example = composedToolNames[0] || 'example_tool';
354
+ return `<!doctype html>
355
+ <html>
356
+ <head>
357
+ <meta charset="utf-8">
358
+ <title>${slug}</title>
359
+ <style>
360
+ :root { --bg:#FBFAF7; --fg:#1F2328; --muted:#8A8F98; --ok:#1A7F37; --err:#C0392B; --brand:#0891B2; --mono:ui-monospace,Menlo,monospace; }
361
+ body { margin:0; padding:20px 24px; background:var(--bg); color:var(--fg); font:14px/1.55 -apple-system,system-ui,sans-serif; }
362
+ h1 { font-size:17px; margin:0 0 2px; } .lede { color:var(--muted); font-size:12px; margin:0 0 14px; max-width:900px; }
363
+ .row { display:flex; gap:8px; align-items:center; margin:12px 0 12px; flex-wrap:wrap; }
364
+ input, button, select { font:inherit; padding:6px 10px; border:1px solid #D5D3CB; border-radius:4px; background:#fff; }
365
+ button { cursor:pointer; } button.danger { color:var(--err); }
366
+ #live { color:var(--muted); font-size:11px; }
367
+ #status { color:var(--muted); font-size:11px; min-height:16px; }
368
+ #status.err { color:var(--err); } #status.ok { color:var(--ok); }
369
+ .item { border-bottom:1px solid #F0EFEA; padding:10px 0; }
370
+ .idPill { display:inline-block; padding:1px 6px; border-radius:10px; font-size:10px; background:#F0EFEA; color:var(--muted); font-family:var(--mono); margin-right:6px; }
371
+ .topicPill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:10px; background:#E0F2FE; color:var(--brand); margin-right:6px; }
372
+ .title { font-weight:600; }
373
+ .src { color:var(--muted); font-size:12px; word-break:break-all; }
374
+ .notes { color:#4a4d52; font-size:13px; margin-top:4px; }
375
+ .meta { color:var(--muted); font-size:11px; font-family:var(--mono); margin-top:2px; }
376
+ .btnDrop { float:right; font-size:11px; }
377
+ </style>
378
+ </head>
379
+ <body>
380
+ <h1>${slug} <span id="live">· connecting…</span></h1>
381
+ <p class="lede">Items the agent saves via <code>save_item</code> land here — live. Composed from <code>pi:${packageName}</code>. Cross-process pulse updates the panel even when the agent runs in another terminal.</p>
382
+
383
+ <div class="row">
384
+ <input id="topic" placeholder="Filter by topic…" />
385
+ <span id="count" class="muted"></span>
386
+ <span id="status" style="margin-left:auto"></span>
387
+ </div>
388
+
389
+ <div id="items"><p class="muted" style="padding:14px">loading…</p></div>
390
+
391
+ <script>
392
+ const token = new URLSearchParams(location.search).get('token') || '';
393
+ const PLUGIN = ${JSON.stringify(slug)};
394
+
395
+ function setStatus(msg, tone) {
396
+ const el = document.getElementById('status');
397
+ el.textContent = msg || ''; el.className = tone || '';
398
+ }
399
+ async function state(op, extra = {}) {
400
+ const res = await fetch('/api/plugin-state/' + PLUGIN, {
401
+ method: 'POST',
402
+ headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
403
+ body: JSON.stringify({ op, ...extra }),
404
+ });
405
+ const body = await res.json();
406
+ if (!res.ok || body.ok === false) throw new Error(body.error || body.message || 'state op failed');
407
+ return body.result;
408
+ }
409
+ async function tool(name, args = {}) {
410
+ const res = await fetch('/api/tools/execute', {
411
+ method: 'POST',
412
+ headers: { 'Content-Type': 'application/json', 'X-Bahulam-Local-Token': token },
413
+ body: JSON.stringify({ name, args }),
414
+ });
415
+ const body = await res.json();
416
+ if (!res.ok || body.ok === false) throw new Error(body.error || 'tool call failed');
417
+ if (body.result?.success === false) throw new Error(String(body.result.output));
418
+ return body.result;
419
+ }
420
+ function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
421
+ function fmtTime(iso) { return (iso || '').replace('T', ' ').slice(0, 19); }
422
+
423
+ async function reload() {
424
+ const container = document.getElementById('items');
425
+ try {
426
+ const filter = document.getElementById('topic').value.trim().toLowerCase();
427
+ const rows = await state('list', { stream: 'items', limit: 500, order: 'desc' });
428
+ const items = (rows || []).filter(r => !filter || String(r.payload?.topic || '').toLowerCase() === filter);
429
+ document.getElementById('count').textContent = \`\${items.length} item\${items.length === 1 ? '' : 's'}\`;
430
+ if (!items.length) {
431
+ container.innerHTML = '<p class="muted" style="padding:14px">no items yet — ask the ${slug} agent</p>';
432
+ return;
433
+ }
434
+ container.innerHTML = items.map(r => {
435
+ const p = r.payload || {};
436
+ return \`<div class="item">
437
+ <button class="btnDrop danger" data-drop="\${r.id}">Drop</button>
438
+ <span class="idPill">#\${r.id}</span>\${p.topic ? \`<span class="topicPill">\${escapeHtml(p.topic)}</span>\` : ''}
439
+ <span class="title">\${escapeHtml(p.title || '')}</span>
440
+ \${p.source ? \`<div class="src"><a href="\${escapeHtml(p.source)}" target="_blank" rel="noreferrer">\${escapeHtml(p.source)}</a></div>\` : ''}
441
+ \${p.notes ? \`<div class="notes">\${escapeHtml(p.notes)}</div>\` : ''}
442
+ <div class="meta">\${fmtTime(r.created_at || p.at)}</div>
443
+ </div>\`;
444
+ }).join('');
445
+ container.querySelectorAll('button[data-drop]').forEach(btn => {
446
+ btn.addEventListener('click', async () => {
447
+ try { await tool('drop_item', { id: Number(btn.getAttribute('data-drop')) }); setStatus('dropped', 'ok'); }
448
+ catch (err) { setStatus(err.message, 'err'); }
449
+ });
450
+ });
451
+ } catch (err) {
452
+ container.innerHTML = \`<p style="padding:14px;color:var(--err)">\${escapeHtml(err.message)}</p>\`;
453
+ }
454
+ }
455
+
456
+ let _t = null;
457
+ const scheduleReload = () => { clearTimeout(_t); _t = setTimeout(reload, 200); };
458
+ document.getElementById('topic').addEventListener('input', scheduleReload);
459
+
460
+ try {
461
+ const es = new EventSource('/api/events?token=' + encodeURIComponent(token));
462
+ es.addEventListener('open', () => { document.getElementById('live').textContent = '· live'; });
463
+ es.addEventListener('error', () => { document.getElementById('live').textContent = '· reconnecting…'; });
464
+ es.addEventListener('plugin_state_changed', (ev) => {
465
+ let evt = {}; try { evt = JSON.parse(ev.data || '{}'); } catch {}
466
+ if (evt.plugin !== PLUGIN) return;
467
+ scheduleReload();
468
+ });
469
+ } catch { /* SSE unavailable; still load-only */ }
470
+
471
+ reload();
472
+ </script>
473
+ </body>
474
+ </html>
475
+ `;
476
+ }
477
+
478
+ /**
479
+ * Scaffold a full pack directory from an installed pi ingredient.
480
+ *
481
+ * @param {Object} opts
482
+ * @param {string} opts.packageName — pi package name (e.g. "pi-web-access")
483
+ * @param {string} [opts.versionRange] — pi package version range
484
+ * @param {string} opts.piDir — dir where pi package is installed (~/.bahulam/plugins-pi/<safe>/)
485
+ * @param {string} opts.targetDir — parent dir for the generated pack
486
+ * @param {Object} opts.discoveredTools — parsed .bahulam-tools.json
487
+ * @param {boolean} [opts.state=true] — include persistent state layer + native tools
488
+ * @param {boolean} [opts.workspace=true] — include reactive panel
489
+ * @param {string} [opts.slug] — override the derived slug
490
+ * @param {boolean} [opts.force=false] — overwrite an existing pack
491
+ * @returns {{ dest: string, slug: string, namespace: string, exposeTools: string[] }}
492
+ */
493
+ export function scaffoldPiPack({
494
+ packageName,
495
+ versionRange,
496
+ piDir,
497
+ targetDir,
498
+ discoveredTools,
499
+ state = true,
500
+ workspace = true,
501
+ slug: slugOverride,
502
+ force = false,
503
+ }) {
504
+ const slug = slugOverride || deriveSlug(packageName);
505
+ const namespace = deriveNamespace(packageName);
506
+ const toolNames = (discoveredTools?.tools || []).map(t => t.name).filter(Boolean);
507
+ if (!toolNames.length) {
508
+ throw new Error(`pi package ${packageName} has no discoverable tools — cannot scaffold`);
509
+ }
510
+
511
+ const dest = path.join(targetDir, slug);
512
+ if (fs.existsSync(dest)) {
513
+ if (!force) throw new Error(`pack already exists: ${dest} (use --force to overwrite)`);
514
+ fs.rmSync(dest, { recursive: true, force: true });
515
+ }
516
+ fs.mkdirSync(dest, { recursive: true });
517
+
518
+ const agentSlug = `${namespace}-specialist`;
519
+ const agentDescription = truncate(
520
+ `Specialist agent for ${packageName}. Composes ${toolNames.length} tool${toolNames.length === 1 ? '' : 's'} exposed as ${namespace}${COMPOSED_TOOL_SEPARATOR}*.`,
521
+ 240,
522
+ );
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);
533
+
534
+ const manifest = renderManifest({
535
+ slug,
536
+ packageName,
537
+ versionRange,
538
+ namespace,
539
+ exposeTools: toolNames,
540
+ agentSlug,
541
+ agentDescription,
542
+ hasState: state,
543
+ hasWorkspace: workspace,
544
+ systemPrompt,
545
+ });
546
+ fs.writeFileSync(path.join(dest, 'plugin.yaml'), manifest);
547
+
548
+ if (state) {
549
+ const toolsDir = path.join(dest, 'tools');
550
+ fs.mkdirSync(toolsDir, { recursive: true });
551
+ fs.writeFileSync(path.join(toolsDir, 'save-item.mjs'), SAVE_ITEM_TOOL);
552
+ fs.writeFileSync(path.join(toolsDir, 'list-items.mjs'), LIST_ITEMS_TOOL);
553
+ fs.writeFileSync(path.join(toolsDir, 'drop-item.mjs'), DROP_ITEM_TOOL);
554
+ }
555
+
556
+ if (workspace) {
557
+ const wsDir = path.join(dest, 'workspace');
558
+ fs.mkdirSync(wsDir, { recursive: true });
559
+ fs.writeFileSync(path.join(wsDir, 'panel.html'), renderPanel(slug, packageName, toolNames));
560
+ }
561
+
562
+ return { dest, slug, namespace, exposeTools: toolNames, agentSlug };
563
+ }