@bitpub/cli 2.1.1 → 2.1.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitpub/cli",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "BitPub CLI — local-first shared memory for AI agents. Six daily verbs (save/load/list/find/sync/delete), zero-config private namespace, encrypted client-side.",
5
5
  "bin": {
6
6
  "bitpub": "./bin/bitpub.js"
@@ -241,6 +241,90 @@ valid pattern wherever a scope is accepted.
241
241
 
242
242
  ---
243
243
 
244
+ ## Publishing apps
245
+
246
+ BitPub can render HTML apps directly in its browser (`bitpub browser`,
247
+ served at `http://localhost:4141`). An "app" is just three slices saved
248
+ at the same prefix — no CLI release, no plugin install, no extension API.
249
+ Save the slices and the app appears in every member's BitPub Browser the
250
+ moment their next `bitpub sync` lands.
251
+
252
+ | Slice | What it is | Format |
253
+ |---|---|---|
254
+ | `<prefix>` | The app shell — rendered in a sandboxed iframe | `text/html` |
255
+ | `<prefix>/manifest.json` | Declarative list of functions the app may call | `application/json` |
256
+ | `<prefix>/<orchestrator>` | (Optional) script the app's functions execute | `text/plain` (or `text/x-python`, etc.) |
257
+
258
+ Inside the iframe an app calls:
259
+
260
+ ```js
261
+ window.bitpub.identity.me() // → { owner, domain, private_prefix }
262
+ window.bitpub.namespace.list({ pattern }) // local SQLite read; no network, no LLM
263
+ window.bitpub.run(functionId, args) // dispatched through the manifest
264
+ window.bitpub.navigate(hcu) // open another slice in the browser
265
+ ```
266
+
267
+ `window.bitpub.run` does not execute arbitrary commands. It looks up
268
+ `functionId` in the app's manifest and dispatches to one of two primitives
269
+ the CLI bridge exposes:
270
+
271
+ - **`script.run`** — `bitpub load <script_hcu>` to a temp file, then run
272
+ it with the named interpreter (`python3`, `node`, `bash`, `sh`). Reads
273
+ `env` and `cli_args` from the manifest, with values resolved from the
274
+ caller's `args`, the running user's `identity`, or literals.
275
+ - **`os.open`** — `open` a URL or local path (Finder reveal, or
276
+ open-in-app).
277
+
278
+ A minimal manifest:
279
+
280
+ ```json
281
+ {
282
+ "id": "team.example",
283
+ "name": "Example",
284
+ "version": "0.1.0",
285
+ "functions": {
286
+ "ingest": {
287
+ "primitive": "script.run",
288
+ "params": {
289
+ "script_hcu": "bitpub://group:<domain>/Apps/example/orchestrator",
290
+ "runtime": "python3"
291
+ },
292
+ "env": {
293
+ "BITPUB_OWNER": { "from": "identity", "key": "owner" },
294
+ "TARGET": { "from": "args", "key": "target" }
295
+ }
296
+ },
297
+ "open_docs": {
298
+ "primitive": "os.open",
299
+ "params": { "url": "https://example.com/docs" }
300
+ }
301
+ }
302
+ }
303
+ ```
304
+
305
+ Two rules of thumb that keep apps portable across teammates:
306
+
307
+ - **Read user identity at runtime** in both the orchestrator and the
308
+ manifest (via `{ "from": "identity", "key": "owner" }`). Never hardcode
309
+ an `agent_xxx` id — it breaks the moment a different teammate runs the
310
+ app.
311
+ - **Split ingest from read.** Ingest (write to BitPub) is the slow,
312
+ credentialed step. Once data is local, every read should be a
313
+ deterministic `namespace.list` / `bitpub load` — no LLM, no API call.
314
+
315
+ Before writing a new app, read the full publishing guide. It's hosted
316
+ alongside this skill so it's always reachable from a fresh install:
317
+
318
+ ```bash
319
+ curl -fsSL {{API_BASE_URL}}/publishing-apps.md
320
+ ```
321
+
322
+ If the team has shipped its own copy at
323
+ `bitpub://group:<domain>/Docs/how-to-publish-a-bitpub-app`, prefer that
324
+ — it usually links to in-tree example apps you can fork from.
325
+
326
+ ---
327
+
244
328
  ## Writing scripts that use BitPub
245
329
 
246
330
  Scripts should read `owner` and `domain` from `~/.bitpub/config.json` at
@@ -110,258 +110,126 @@ const ASSET_MIME = {
110
110
 
111
111
  /* ── Bridge (apps → local CLI) ──────────────────────────────────────
112
112
  *
113
- * Sandboxed app iframes (default-src 'none', no allow-same-origin)
114
- * cannot fetch, but they CAN postMessage to the parent window. The
115
- * parent is the BitPub Browser shell; it forwards approved calls to
116
- * this CLI process, which spawns a child and streams stdout/stderr
117
- * back over Server-Sent Events.
113
+ * Sandboxed app iframes can postMessage to the parent BitPub Browser
114
+ * shell, which forwards approved calls to this CLI process. The CLI
115
+ * spawns a child and streams stdout/stderr back over SSE.
118
116
  *
119
- * Security floor for v1:
120
- * - Only functions listed in FUNCTIONS are runnable. Apps cannot
121
- * execute arbitrary shell.
122
- * - Arguments are typed and validated per function — no string
123
- * interpolation into the shell.
124
- * - The HTTP server binds to localhost only (default port 4141),
125
- * so the surface is the same trust boundary as the CLI itself:
126
- * anyone with shell access already has more power than the bridge.
127
- * - The next layer (permission grants per app + audit log) is a
128
- * console.html responsibility, not the CLI's.
117
+ * SUBSTRATE PRIMITIVES (this file):
118
+ * - script.run load a slice as a script + run with an interpreter
119
+ * - os.open open a URL in the default browser, or a path locally
120
+ * - identity.me who is this BitPub install (separate /bridge/identity endpoint)
129
121
  *
130
- * Functions in v1 are hardcoded here. The end state — functions
131
- * resolved from slices, e.g. bitpub://group:tollbit.com/Functions/<id>
132
- * needs the same permission UX and a small sandbox for the script,
133
- * so we ship the registry shape first and back-fill the resolver.
122
+ * APP-SPECIFIC FUNCTIONS (live in the namespace as manifest.json slices):
123
+ * Apps declare their own named functions in a manifest sibling slice,
124
+ * e.g. bitpub://group:tollbit.com/Apps/github/manifest.json. The
125
+ * Browser shell (console.html) reads that manifest, resolves each
126
+ * function call to a script.run or os.open invocation, and forwards
127
+ * to the bridge. This means no CLI release is needed to publish a
128
+ * new app — anyone in a group can save HTML + script + manifest and
129
+ * ship it.
130
+ *
131
+ * Trust model: same as installing an npm package. Anything the script
132
+ * can do, the orchestrator can do — we don't sandbox the child process.
133
+ * The browser shell is responsible for the install consent UI; the CLI
134
+ * only enforces input shape on the primitives.
134
135
  */
135
136
  const FUNCTIONS = {
136
- 'granola.ingest': {
137
- description: 'Pull new Granola meetings into your private namespace.',
138
- build(args) {
139
- const limit = clampInt(args && args.limit, 1, 200, 5);
140
- const script =
141
- 'set -e; ' +
142
- 'bitpub load bitpub://group:tollbit.com/Agents/granola-ingest/script > "$T" && ' +
143
- `python3 "$T" --limit ${limit}`;
144
- return {
145
- cmd: 'bash',
146
- args: ['-lc', script],
147
- env: { T: path.join(os.tmpdir(), `.bitpub-fn-granola-ingest.py`) },
148
- };
149
- },
150
- },
151
137
 
152
- // Orchestration script for sales_brief.generate lives as a slice at
153
- // bitpub://private:.../Apps/sales-brief-generator/orchestrator so the
154
- // logic can be iterated without restarting the CLI. The function below
155
- // is the thin entry point that loads + runs it.
156
- 'sales_brief.generate': {
157
- description: 'Synthesize a bespoke sales brief PDF from selected call transcripts.',
138
+ // ── Substrate primitive 1: load + run a script slice ───────
139
+ // Everything else (ingest scripts, generators, summarizers) is
140
+ // implementable on top of this. Args are deliberately flat so a
141
+ // declarative app manifest can construct them without a build step.
142
+ 'script.run': {
143
+ description: 'Load a slice as a script and run it with the given interpreter.',
158
144
  build(args) {
159
145
  const a = args || {};
160
- const transcriptHcus = Array.isArray(a.transcript_hcus) ? a.transcript_hcus.slice(0, 25) : [];
161
- const template = String(a.template || 'discovery').slice(0, 32).replace(/[^a-z_]/gi, '');
162
- const customer = String(a.customer || '').slice(0, 120);
163
- const dealStage = String(a.deal_stage || '').slice(0, 80);
164
- const extraNotes = String(a.extra_notes || '').slice(0, 2000);
165
- const outputDir = path.join(os.homedir(), 'Downloads', 'Briefs');
166
-
167
- if (transcriptHcus.length === 0) {
168
- throw new Error('no transcripts selected');
146
+ const hcu = String(a.script_hcu || a.script || '').trim();
147
+ const runtime = String(a.runtime || 'python3').trim();
148
+ const env = (a.env && typeof a.env === 'object') ? a.env : {};
149
+ const cliArgs = Array.isArray(a.cli_args) ? a.cli_args : [];
150
+
151
+ // HCU must be a real bitpub:// address and free of shell metachars.
152
+ // (We re-embed it into the bash -lc string below, so be paranoid.)
153
+ if (!/^bitpub:\/\/(private|group|public):[A-Za-z0-9._-]+\/[A-Za-z0-9._\/\-]+$/.test(hcu)) {
154
+ throw new Error('invalid script_hcu');
169
155
  }
156
+ if (/[\s"'`$;&|<>\\]/.test(hcu)) throw new Error('script_hcu contains unsafe characters');
170
157
 
171
- const payload = JSON.stringify({
172
- transcript_hcus: transcriptHcus,
173
- template,
174
- customer,
175
- deal_stage: dealStage,
176
- extra_notes: extraNotes,
177
- output_dir: outputDir,
178
- });
179
-
180
- const script =
181
- 'set -e; ' +
182
- 'bitpub load bitpub://private:agent_ewim9gf01nq3/Apps/sales-brief-generator/orchestrator > "$ORCH" && ' +
183
- 'python3 "$ORCH"';
184
-
185
- return {
186
- cmd: 'bash',
187
- args: ['-lc', script],
188
- env: {
189
- ORCH: path.join(os.tmpdir(), '.bitpub-fn-sales-brief.py'),
190
- BRIEF_PAYLOAD: payload,
191
- },
192
- };
193
- },
194
- },
158
+ const ALLOWED_RUNTIMES = ['python3', 'python', 'node', 'bash', 'sh'];
159
+ if (!ALLOWED_RUNTIMES.includes(runtime)) {
160
+ throw new Error(`runtime "${runtime}" not allowed (use one of ${ALLOWED_RUNTIMES.join(', ')})`);
161
+ }
195
162
 
196
- 'sales_brief.open': {
197
- description: 'Open a generated PDF in the default viewer.',
198
- build(args) {
199
- const p = String((args && args.path) || '');
200
- if (!p || !p.startsWith('/') || p.includes('"')) throw new Error('invalid path');
201
- return { cmd: 'bash', args: ['-lc', `open "${p}"`] };
202
- },
203
- },
163
+ // Sanitize env: only POSIX-ish var names, stringify values.
164
+ const cleanEnv = {};
165
+ for (const [k, v] of Object.entries(env)) {
166
+ if (!/^[A-Z_][A-Z0-9_]{0,63}$/i.test(k)) continue;
167
+ if (v == null) continue;
168
+ cleanEnv[k] = (typeof v === 'string') ? v : JSON.stringify(v);
169
+ }
204
170
 
205
- 'sales_brief.reveal': {
206
- description: 'Reveal a generated PDF in Finder.',
207
- build(args) {
208
- const p = String((args && args.path) || '');
209
- if (!p || !p.startsWith('/') || p.includes('"')) throw new Error('invalid path');
210
- return { cmd: 'bash', args: ['-lc', `open -R "${p}"`] };
211
- },
212
- },
171
+ // Sanitize CLI args: stringify, reject anything with shell metachars.
172
+ const cleanCliArgs = [];
173
+ for (const x of cliArgs) {
174
+ const s = String(x);
175
+ if (s.length > 256) throw new Error('cli arg too long');
176
+ if (/[`$;&|<>\\\n]/.test(s)) throw new Error('cli arg contains unsafe characters');
177
+ cleanCliArgs.push(s);
178
+ }
213
179
 
214
- // ── GitHub Pack ────────────────────────────────────────────
215
- // The orchestrator slice lives at a GROUP address so every teammate
216
- // in the group can load the same logic — they don't fork it. Each
217
- // entry point injects BITPUB_OWNER from local config so the script
218
- // writes to *the running user's* private namespace, not the author's.
219
- //
220
- // Auth model: orchestrator probes (1) macOS keychain entry from
221
- // GitHub Desktop, (2) gh CLI, (3) BitPub-managed token. Tokens are
222
- // never passed through args/URLs — child reads them itself so
223
- // secrets never traverse the bridge.
224
-
225
- 'github.check_auth': {
226
- description: 'Detect available GitHub credentials (Desktop keychain, gh CLI, BitPub-managed token).',
227
- build() {
228
- return {
229
- cmd: 'bash',
230
- args: ['-lc',
231
- 'set -e; ' +
232
- 'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
233
- 'python3 "$T"',
234
- ],
235
- env: {
236
- T: path.join(os.tmpdir(), '.bitpub-fn-github-authcheck.py'),
237
- BITPUB_GH_ACTION: 'auth_check',
238
- BITPUB_OWNER: (readConfig() || {}).owner || '',
239
- },
240
- };
180
+ // Unique temp file per invocation so two concurrent runs of the
181
+ // same script can't trample each other.
182
+ const tag = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
183
+ const T = path.join(os.tmpdir(), `.bitpub-script-${tag}`);
184
+ cleanEnv.T = T;
185
+
186
+ // Re-shell-quote CLI args by passing them as positional args to bash
187
+ // via the "_" sentinel, then "$@" preserves quoting.
188
+ const cliPart = cleanCliArgs.length
189
+ ? ' ' + cleanCliArgs.map(a => `"${a.replace(/"/g, '\\"')}"`).join(' ')
190
+ : '';
191
+ const script = `set -e; bitpub load "${hcu}" > "$T" && ${runtime} "$T"${cliPart}`;
192
+ return { cmd: 'bash', args: ['-lc', script], env: cleanEnv };
241
193
  },
242
194
  },
243
195
 
244
- 'github.ingest': {
245
- description: 'Mirror your GitHub PRs, issues, and recent activity into your namespace.',
196
+ // ── Substrate primitive 2: open a URL or a local path ──────
197
+ // Wraps macOS `open` (the only OS-level capability apps reach for
198
+ // often enough to belong in the substrate). Manifests can declare
199
+ // url_pattern allowlists for extra hygiene — enforced at resolver
200
+ // time in the shell, not here.
201
+ 'os.open': {
202
+ description: 'Open a URL in the default browser, or a path in Finder / a specific app.',
246
203
  build(args) {
247
204
  const a = args || {};
248
- const recipes = Array.isArray(a.recipes) && a.recipes.length
249
- ? a.recipes.filter(r => typeof r === 'string' && /^[a-z_]+$/.test(r)).slice(0, 8)
250
- : ['prs_review_requested', 'prs_mine', 'prs_org_recent', 'issues_assigned', 'activity_recent'];
251
- return {
252
- cmd: 'bash',
253
- args: ['-lc',
254
- 'set -e; ' +
255
- 'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
256
- 'python3 "$T"',
257
- ],
258
- env: {
259
- T: path.join(os.tmpdir(), '.bitpub-fn-github-ingest.py'),
260
- BITPUB_GH_RECIPES: recipes.join(','),
261
- BITPUB_OWNER: (readConfig() || {}).owner || '',
262
- },
263
- };
264
- },
265
- },
266
-
267
- 'github.open': {
268
- description: 'Open a GitHub URL in the default browser.',
269
- build(args) {
270
- const url = String((args && args.url) || '');
271
- if (!/^https:\/\/github\.com\//.test(url) || url.includes('"')) {
272
- throw new Error('invalid github url');
205
+ const url = a.url ? String(a.url) : null;
206
+ const filePath = a.path ? String(a.path) : null;
207
+ const reveal = !!a.reveal;
208
+ const inApp = a.in ? String(a.in) : null;
209
+
210
+ if (url) {
211
+ if (!/^https?:\/\//.test(url) || /["`$;&|<>\\]/.test(url)) throw new Error('invalid url');
212
+ return { cmd: 'bash', args: ['-lc', `open "${url}"`] };
273
213
  }
274
- return { cmd: 'bash', args: ['-lc', `open "${url}"`] };
275
- },
276
- },
277
-
278
- 'github.open_clone': {
279
- description: 'Open a repo\'s local clone in Finder (or VS Code if "in" === "vscode").',
280
- build(args) {
281
- const repo = String((args && args.repo) || '');
282
- const where = String((args && args.in) || 'finder');
283
- if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) {
284
- throw new Error('invalid repo "owner/name"');
214
+ if (filePath) {
215
+ if (!filePath.startsWith('/') || /["`$;&|<>\\]/.test(filePath)) throw new Error('invalid path');
216
+ let flag = '';
217
+ if (reveal) flag = '-R ';
218
+ else if (inApp) {
219
+ if (!/^[A-Za-z0-9. _-]{1,64}$/.test(inApp)) throw new Error('invalid app name');
220
+ flag = `-a "${inApp}" `;
221
+ }
222
+ return { cmd: 'bash', args: ['-lc', `open ${flag}"${filePath}"`] };
285
223
  }
286
- const opener = where === 'vscode' ? 'code' :
287
- where === 'iterm' ? `open -a iTerm` :
288
- 'open';
289
- // Search common project roots for a directory whose `git remote -v`
290
- // points at this repo. Local-first move: we surface the user's
291
- // clone if they have one, instead of dumping them at github.com.
292
- const script = `
293
- set -e
294
- REPO='${repo}'
295
- ROOTS=(~/projects ~/code ~/dev ~/src ~/Documents/projects ~/work)
296
- for root in "\${ROOTS[@]}"; do
297
- [ -d "$root" ] || continue
298
- while IFS= read -r dir; do
299
- if (cd "$dir" && git remote -v 2>/dev/null) | grep -qiE "[:/]\${REPO}(\\.git)?( |\\\$)"; then
300
- echo "found clone: $dir"
301
- ${opener} "$dir"
302
- exit 0
303
- fi
304
- done < <(find "$root" -mindepth 1 -maxdepth 3 -type d -name .git -prune -exec dirname {} \\; 2>/dev/null)
305
- done
306
- echo "no local clone of $REPO found in common roots — opening on github.com instead"
307
- open "https://github.com/$REPO"
308
- `;
309
- return { cmd: 'bash', args: ['-lc', script] };
224
+ throw new Error('os.open needs either url or path');
310
225
  },
311
226
  },
312
227
 
313
- 'github.summarize_pr': {
314
- description: 'Summarize a PR with claude (the only LLM-in-the-loop step).',
315
- build(args) {
316
- const hcu = String((args && args.hcu) || '');
317
- // Any private namespace, as long as it's pointed at GitHub/PRs/.
318
- // We constrain the shape so an app can't trick the bridge into
319
- // pulling arbitrary slices through claude.
320
- if (!/^bitpub:\/\/private:[a-z0-9_-]+\/GitHub\/PRs\//i.test(hcu) || hcu.includes('"')) {
321
- throw new Error('invalid PR hcu');
322
- }
323
- // Deterministic read (bitpub load) -> single LLM call (claude -p) ->
324
- // structured text back to the UI. No MCP, no network round-trip for
325
- // the data — only for the synthesis.
326
- const script =
327
- 'set -e; ' +
328
- `bitpub load "${hcu}" > "$T" && ` +
329
- 'claude -p "$(printf \'%s\\n\\nSummarize this GitHub PR in 3 short bullets: what it does, who it affects, anything risky. Be concrete.\' "$(cat "$T")")" ' +
330
- '--output-format text --permission-mode bypassPermissions';
331
- return {
332
- cmd: 'bash',
333
- args: ['-lc', script],
334
- env: { T: path.join(os.tmpdir(), `.bitpub-fn-github-summarize.md`) },
335
- };
336
- },
337
- },
228
+ // ───────────────────────────────────────────────────────────
229
+ // (Apps now declare their functions in manifest.json slices
230
+ // see backend/static/console.html#resolveManifestFunction.)
231
+ // ───────────────────────────────────────────────────────────
338
232
 
339
- 'github.action.approve': {
340
- description: 'Approve a pull request via the GitHub API (uses inherited credentials).',
341
- build(args) {
342
- const repo = String((args && args.repo) || '');
343
- const number = parseInt((args && args.number), 10);
344
- const body = String((args && args.body) || '');
345
- if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(repo)) throw new Error('invalid repo');
346
- if (!Number.isFinite(number) || number <= 0) throw new Error('invalid PR number');
347
- return {
348
- cmd: 'bash',
349
- args: ['-lc',
350
- 'set -e; ' +
351
- 'bitpub load bitpub://group:tollbit.com/Apps/github/orchestrator > "$T" && ' +
352
- 'python3 "$T"',
353
- ],
354
- env: {
355
- T: path.join(os.tmpdir(), '.bitpub-fn-github-action.py'),
356
- BITPUB_GH_ACTION: 'approve',
357
- BITPUB_GH_REPO: repo,
358
- BITPUB_GH_PR: String(number),
359
- BITPUB_GH_BODY: body,
360
- BITPUB_OWNER: (readConfig() || {}).owner || '',
361
- },
362
- };
363
- },
364
- },
365
233
  };
366
234
 
367
235
  function clampInt(value, lo, hi, dflt) {
@@ -2490,7 +2490,7 @@ function renderSlice(sl) {
2490
2490
  if (S.raw) {
2491
2491
  html += `<pre class="content-raw">${esc(content)}</pre>`;
2492
2492
  } else if (canRenderAsApp) {
2493
- html += renderHtmlAppFrame(content);
2493
+ html += renderHtmlAppFrame(content, sl.hcu);
2494
2494
  } else if (kind === 'html') {
2495
2495
  html += `<div class="app-public-note">${ICON.info}
2496
2496
  <div>HTML apps from <code>public:</code> addresses are shown as source until the public sandbox model lands. Apps in <code>private:</code> and <code>group:</code> scopes render inline.</div>
@@ -2555,14 +2555,21 @@ function detectContentKind(sl, content) {
2555
2555
  * - The iframe never reaches the network itself — only the parent
2556
2556
  * does. CSP can stay locked down (default-src 'none').
2557
2557
  */
2558
- function renderHtmlAppFrame(html) {
2558
+ function renderHtmlAppFrame(html, appHcu) {
2559
2559
  const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; font-src data:; base-uri 'none'; form-action 'none'">`;
2560
2560
  const baseTag = `<base target="_blank">`;
2561
+ // Inject the app's own HCU into the iframe so the bridge shim can tag
2562
+ // every postMessage. The parent uses this to look up the calling app's
2563
+ // manifest and resolve short-named functions to script.run / os.open
2564
+ // invocations. (Apps could lie about this in v1 — we trust app code
2565
+ // the same way you trust an npm package.)
2566
+ const safeHcu = String(appHcu || '').replace(/['"\\]/g, '');
2567
+ const hcuConst = `<\x73cript>window.__BITPUB_APP_HCU__='${safeHcu}';<\/script>`;
2561
2568
  // The shim tag uses \x73 + escaped slash so the closing pattern can't
2562
2569
  // be detected by the outer HTML parser. The escapes are transparent to
2563
2570
  // the JS parser but defeat the HTML scanner's greedy script-end match.
2564
2571
  const shim = `<\x73cript>${BRIDGE_SHIM_SOURCE}<\/script>`;
2565
- const headInject = `${csp}${baseTag}${shim}`;
2572
+ const headInject = `${csp}${baseTag}${hcuConst}${shim}`;
2566
2573
 
2567
2574
  let doc;
2568
2575
  if (/<head[\s>]/i.test(html)) {
@@ -2648,6 +2655,7 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2648
2655
  type: 'bitpub.run',
2649
2656
  requestId: requestId,
2650
2657
  functionId: String(functionId),
2658
+ appHcu: window.__BITPUB_APP_HCU__ || '',
2651
2659
  args: args || {}
2652
2660
  }, '*');
2653
2661
  return requestId;
@@ -2784,13 +2792,151 @@ function bridgeForward(source, requestId, event, payload) {
2784
2792
  } catch (_) { /* iframe gone */ }
2785
2793
  }
2786
2794
 
2787
- function handleBridgeRunMessage(source, msg) {
2788
- const { requestId, functionId, args } = msg;
2795
+ /* ── Manifest-driven app function dispatch ─────────────────────────
2796
+ *
2797
+ * Apps declare their callable functions in a sibling slice named
2798
+ * `manifest.json`. The parent browser shell (this file) reads that
2799
+ * manifest the first time the app calls bitpub.run(), then resolves
2800
+ * every subsequent call to a CLI substrate primitive — without the
2801
+ * CLI needing to know anything about the app.
2802
+ *
2803
+ * Manifest shape:
2804
+ * {
2805
+ * "id": "tollbit.github",
2806
+ * "name": "GitHub Pack",
2807
+ * "version": "0.1.0",
2808
+ * "functions": {
2809
+ * "ingest": {
2810
+ * "primitive": "script.run",
2811
+ * "params": {
2812
+ * "script_hcu": "bitpub://group:tollbit.com/Apps/github/orchestrator",
2813
+ * "runtime": "python3"
2814
+ * },
2815
+ * "env": {
2816
+ * "BITPUB_GH_ACTION": "ingest",
2817
+ * "BITPUB_GH_RECIPES": { "from": "args", "key": "recipes", "join": "," },
2818
+ * "BITPUB_OWNER": { "from": "identity", "key": "owner" }
2819
+ * }
2820
+ * }
2821
+ * }
2822
+ * }
2823
+ *
2824
+ * Field values can be:
2825
+ * - a literal string → used as-is
2826
+ * - { from: "args", key: "...", join?: "," } → arg-derived
2827
+ * - { from: "identity", key: "..." } → from identity.me()
2828
+ */
2829
+
2830
+ const MANIFEST_CACHE = new Map(); // appHcu → Promise<manifest|null>
2831
+ const PRIMITIVE_IDS = new Set(['script.run', 'os.open']);
2832
+
2833
+ async function loadAppManifest(appHcu) {
2834
+ if (!appHcu) return null;
2835
+ if (MANIFEST_CACHE.has(appHcu)) return MANIFEST_CACHE.get(appHcu);
2836
+ const p = (async () => {
2837
+ const manifestHcu = appHcu + '/manifest.json';
2838
+ try {
2839
+ const u = new URL('/bridge/namespace/list', location.origin);
2840
+ u.searchParams.set('pattern', manifestHcu);
2841
+ u.searchParams.set('fields', 'full');
2842
+ u.searchParams.set('limit', '1');
2843
+ const r = await fetch(u.toString());
2844
+ const body = await r.json();
2845
+ const slice = (body.slices || [])[0];
2846
+ if (!slice || !slice.content) return null;
2847
+ return JSON.parse(slice.content);
2848
+ } catch (_) {
2849
+ return null;
2850
+ }
2851
+ })();
2852
+ MANIFEST_CACHE.set(appHcu, p);
2853
+ return p;
2854
+ }
2855
+
2856
+ let CACHED_IDENTITY = null;
2857
+ async function getIdentityCached() {
2858
+ if (CACHED_IDENTITY) return CACHED_IDENTITY;
2859
+ try {
2860
+ const r = await fetch('/bridge/identity');
2861
+ CACHED_IDENTITY = await r.json();
2862
+ } catch (_) {
2863
+ CACHED_IDENTITY = {};
2864
+ }
2865
+ return CACHED_IDENTITY;
2866
+ }
2867
+
2868
+ function resolveManifestValue(v, ctx) {
2869
+ if (v == null) return null;
2870
+ // Arrays: resolve each element (used for cli_args).
2871
+ if (Array.isArray(v)) return v.map(x => resolveManifestValue(x, ctx)).filter(x => x != null);
2872
+ if (typeof v !== 'object') return v; // literal string / number
2873
+ const src = v.from === 'args' ? ctx.args : v.from === 'identity' ? ctx.identity : null;
2874
+ if (!src) return null;
2875
+ // Whole-object reference: { from: "args" } with no key → the entire
2876
+ // object. Useful for shipping all caller args as one JSON env var
2877
+ // (e.g. BRIEF_PAYLOAD). The env serializer downstream auto-stringifies
2878
+ // non-string values, so this Just Works.
2879
+ if (!v.key) return src;
2880
+ let val = src[v.key];
2881
+ if (val == null) return v.default != null ? v.default : null;
2882
+ if (v.join && Array.isArray(val)) val = val.join(v.join);
2883
+ return val;
2884
+ }
2885
+
2886
+ function resolveManifestFunction(manifest, funcName, args, identity) {
2887
+ if (!manifest || !manifest.functions) throw new Error('app has no manifest');
2888
+ const decl = manifest.functions[funcName];
2889
+ if (!decl) throw new Error(`function "${funcName}" not declared in app manifest`);
2890
+ if (!PRIMITIVE_IDS.has(decl.primitive)) throw new Error(`unknown primitive "${decl.primitive}"`);
2891
+
2892
+ const ctx = { args: args || {}, identity: identity || {} };
2893
+ const params = {};
2894
+ for (const [k, v] of Object.entries(decl.params || {})) {
2895
+ const resolved = resolveManifestValue(v, ctx);
2896
+ if (resolved != null) params[k] = resolved;
2897
+ }
2898
+ if (decl.primitive === 'script.run' && decl.env) {
2899
+ params.env = params.env || {};
2900
+ for (const [k, v] of Object.entries(decl.env)) {
2901
+ const resolved = resolveManifestValue(v, ctx);
2902
+ if (resolved != null) params.env[k] = resolved;
2903
+ }
2904
+ }
2905
+ return { functionId: decl.primitive, args: params };
2906
+ }
2907
+
2908
+ async function handleBridgeRunMessage(source, msg) {
2909
+ const { requestId, functionId, args, appHcu } = msg;
2789
2910
  if (typeof requestId !== 'number' || !functionId) return;
2790
2911
 
2912
+ // Three paths:
2913
+ // 1. Calling a primitive directly → passthrough (power users + manifests)
2914
+ // 2. Calling a manifest-declared name → resolve, then call primitive
2915
+ // 3. Calling a legacy hardcoded name → passthrough (deprecation path)
2916
+ let resolvedFunctionId = String(functionId);
2917
+ let resolvedArgs = args || {};
2918
+
2919
+ if (!PRIMITIVE_IDS.has(resolvedFunctionId)) {
2920
+ const manifest = await loadAppManifest(appHcu);
2921
+ if (manifest && manifest.functions && manifest.functions[resolvedFunctionId]) {
2922
+ try {
2923
+ const identity = await getIdentityCached();
2924
+ const resolved = resolveManifestFunction(manifest, resolvedFunctionId, resolvedArgs, identity);
2925
+ resolvedFunctionId = resolved.functionId;
2926
+ resolvedArgs = resolved.args;
2927
+ } catch (err) {
2928
+ bridgeForward(source, requestId, 'error', { message: 'manifest resolve: ' + err.message });
2929
+ bridgeForward(source, requestId, 'exit', { code: 2 });
2930
+ return;
2931
+ }
2932
+ }
2933
+ // else: fall through to legacy CLI passthrough (no manifest, name might
2934
+ // still resolve in the CLI's hardcoded FUNCTIONS list).
2935
+ }
2936
+
2791
2937
  const qs = new URLSearchParams({
2792
- functionId: String(functionId),
2793
- args: JSON.stringify(args || {}),
2938
+ functionId: resolvedFunctionId,
2939
+ args: JSON.stringify(resolvedArgs),
2794
2940
  });
2795
2941
 
2796
2942
  let es;
@@ -2802,7 +2948,7 @@ function handleBridgeRunMessage(source, msg) {
2802
2948
  return;
2803
2949
  }
2804
2950
 
2805
- BRIDGE_RUNS.set(requestId, { es, source, functionId });
2951
+ BRIDGE_RUNS.set(requestId, { es, source, functionId: resolvedFunctionId });
2806
2952
  BRIDGE_RUN_COUNT++;
2807
2953
  updateBridgeBadge();
2808
2954