@bitpub/cli 2.1.0 → 2.1.1

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.0",
3
+ "version": "2.1.1",
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"
@@ -210,6 +210,158 @@ const FUNCTIONS = {
210
210
  return { cmd: 'bash', args: ['-lc', `open -R "${p}"`] };
211
211
  },
212
212
  },
213
+
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
+ };
241
+ },
242
+ },
243
+
244
+ 'github.ingest': {
245
+ description: 'Mirror your GitHub PRs, issues, and recent activity into your namespace.',
246
+ build(args) {
247
+ 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');
273
+ }
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"');
285
+ }
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] };
310
+ },
311
+ },
312
+
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
+ },
338
+
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
+ },
213
365
  };
214
366
 
215
367
  function clampInt(value, lo, hi, dflt) {
@@ -415,6 +567,24 @@ function startBrowserServer(opts = {}) {
415
567
  return;
416
568
  }
417
569
 
570
+ // Bridge: who is this BitPub installation? Apps that ship via a
571
+ // group/public namespace can't hardcode an owner ID — they need to
572
+ // know "what is the current user's private prefix" at runtime so
573
+ // their namespace.list() / save() calls hit the right cache.
574
+ // Returns the minimum info needed: owner id, the domain (for
575
+ // display), and the canonical private namespace prefix.
576
+ if (url.pathname === '/bridge/identity') {
577
+ res.setHeader('Content-Type', 'application/json');
578
+ const owner = (cfg && cfg.owner) || null;
579
+ const domain = (cfg && cfg.domain) || null;
580
+ res.end(JSON.stringify({
581
+ owner,
582
+ domain,
583
+ private_prefix: owner ? `bitpub://private:${owner}` : null,
584
+ }));
585
+ return;
586
+ }
587
+
418
588
  // Bridge: list registered functions (for the permission UI to
419
589
  // describe what an app is about to run).
420
590
  if (url.pathname === '/bridge/functions') {
@@ -1613,8 +1613,18 @@ function renderBranch(scope, children, path) {
1613
1613
 
1614
1614
  let html = '';
1615
1615
 
1616
- // Aggregated direct-leaf entry for this level
1617
- if (directLeaves.length > 0) {
1616
+ // Direct leaves: render by name when small enough to be useful as a
1617
+ // package contents listing (e.g. an Apps/github folder showing its
1618
+ // `orchestrator` child by name). Aggregate into a single "N context
1619
+ // slices" summary above a threshold so very large folders (282
1620
+ // transcripts, hundreds of PRs) don't blow up the tree.
1621
+ const LEAF_NAME_THRESHOLD = 8;
1622
+ if (directLeaves.length > 0 && directLeaves.length <= LEAF_NAME_THRESHOLD) {
1623
+ const sorted = [...directLeaves].sort((a, b) => a.name.localeCompare(b.name));
1624
+ for (const { name, slice } of sorted) {
1625
+ html += renderLeaf(slice, name);
1626
+ }
1627
+ } else if (directLeaves.length > LEAF_NAME_THRESHOLD) {
1618
1628
  const mostRecent = directLeaves.reduce(
1619
1629
  (a, b) => ((a.slice.last_synced || '') > (b.slice.last_synced || '') ? a : b)
1620
1630
  );
@@ -1623,7 +1633,11 @@ function renderBranch(scope, children, path) {
1623
1633
  const pathArg = JSON.stringify(path).replace(/"/g, '&quot;');
1624
1634
  const isActiveSummary = S.view === 'namespace' && S.currentScope === scope && arrayEq(S.currentPath, path);
1625
1635
  const when = mostRecent.slice.last_synced ? timeAgo(mostRecent.slice.last_synced) : '';
1626
- html += `<div class="tree-leaf tree-summary${isActiveSummary ? ' active' : ''}" onclick="navigateTo('${escAttr(scope)}', ${pathArg})" title="View ${directLeaves.length} slice${directLeaves.length !== 1 ? 's' : ''} at this level">
1636
+ // Aggregated summary goes to the namespace listing (not the self-slice).
1637
+ // Calls navigateToNamespace to bypass the directory-index resolution in
1638
+ // navigateTo() — clicking "47 PRs" should show the listing, not whatever
1639
+ // happens to live at the parent address.
1640
+ html += `<div class="tree-leaf tree-summary${isActiveSummary ? ' active' : ''}" onclick="navigateToNamespace('${escAttr(scope)}', ${pathArg})" title="View ${directLeaves.length} slice${directLeaves.length !== 1 ? 's' : ''} at this level">
1627
1641
  <span class="fresh-dot ${freshClass}"></span>
1628
1642
  <span class="tree-label">${directLeaves.length} context slice${directLeaves.length !== 1 ? 's' : ''}</span>
1629
1643
  <span class="tree-badge">${esc(when)}</span>
@@ -1934,6 +1948,24 @@ function goRoot() {
1934
1948
  }
1935
1949
 
1936
1950
  function navigateTo(scope, path) {
1951
+ const segs = Array.isArray(path) ? path : [];
1952
+ // Directory-index semantics: if a slice exists AT this folder address
1953
+ // (e.g. an HTML app at `Apps/github` that also has `Apps/github/orchestrator`
1954
+ // as a child), render the slice as the primary view — same as a
1955
+ // browser serving /foo/index.html when you visit /foo/. Children stay
1956
+ // reachable via the tree chevron and named-leaf entries underneath.
1957
+ const selfHcu = `bitpub://${scope}` + (segs.length ? '/' + segs.join('/') : '');
1958
+ const self = S.slices.find(s => s.hcu === selfHcu);
1959
+ if (self) { selectSlice(selfHcu); return; }
1960
+
1961
+ navigateToNamespace(scope, segs);
1962
+ }
1963
+
1964
+ // Explicit "show me the folder listing" navigation. Used by the
1965
+ // aggregated "N context slices" summary in the tree, and anywhere
1966
+ // else we want to skip the directory-index check (e.g. "see all in
1967
+ // this namespace" links).
1968
+ function navigateToNamespace(scope, path) {
1937
1969
  S.view = 'namespace';
1938
1970
  S.currentScope = scope;
1939
1971
  S.currentPath = Array.isArray(path) ? path : [];
@@ -2595,6 +2627,16 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2595
2627
  readHandlers.delete(d.requestId);
2596
2628
  if (d.ok) rh.resolve({ slices: d.slices || [], count: d.count || 0, pattern: d.pattern || '' });
2597
2629
  else rh.reject(new Error(d.error || 'namespace read failed'));
2630
+ } else if (d.type === 'bitpub.identity.result') {
2631
+ var ih = readHandlers.get(d.requestId);
2632
+ if (!ih) return;
2633
+ readHandlers.delete(d.requestId);
2634
+ if (d.ok) ih.resolve({
2635
+ owner: d.owner,
2636
+ domain: d.domain,
2637
+ private_prefix: d.private_prefix,
2638
+ });
2639
+ else ih.reject(new Error(d.error || 'identity lookup failed'));
2598
2640
  }
2599
2641
  });
2600
2642
  window.bitpub = {
@@ -2638,6 +2680,43 @@ const BRIDGE_SHIM_SOURCE = String.raw`
2638
2680
  }, 10000);
2639
2681
  });
2640
2682
  }
2683
+ },
2684
+ navigate: function (hcu) {
2685
+ // Hand off to another slice in the parent BitPub Browser
2686
+ // (e.g. discovery page → installed app). HCU must be a
2687
+ // bitpub:// address; everything else is dropped silently.
2688
+ window.parent.postMessage({
2689
+ __bitpub: true,
2690
+ type: 'bitpub.navigate',
2691
+ hcu: String(hcu || ''),
2692
+ }, '*');
2693
+ },
2694
+ identity: {
2695
+ // me() -> Promise<{ owner, domain, private_prefix }>
2696
+ // owner e.g. "agent_ewim9gf01nq3"
2697
+ // domain e.g. "tollbit.com"
2698
+ // private_prefix e.g. "bitpub://private:agent_ewim9gf01nq3"
2699
+ //
2700
+ // Lets apps that are distributed via a group/public namespace
2701
+ // construct addresses that point at the *running user's* private
2702
+ // data without hardcoding an owner ID. Use this if your app ships
2703
+ // to other people.
2704
+ me: function () {
2705
+ return new Promise(function (resolve, reject) {
2706
+ var requestId = ++nextId;
2707
+ readHandlers.set(requestId, { resolve: resolve, reject: reject });
2708
+ window.parent.postMessage({
2709
+ __bitpub: true,
2710
+ type: 'bitpub.identity.me',
2711
+ requestId: requestId,
2712
+ }, '*');
2713
+ setTimeout(function () {
2714
+ if (!readHandlers.has(requestId)) return;
2715
+ readHandlers.delete(requestId);
2716
+ reject(new Error('identity.me timed out'));
2717
+ }, 5000);
2718
+ });
2719
+ }
2641
2720
  }
2642
2721
  };
2643
2722
  })();
@@ -2794,11 +2873,46 @@ async function handleBridgeNamespaceListMessage(source, msg) {
2794
2873
  }
2795
2874
  }
2796
2875
 
2876
+ async function handleBridgeIdentityMessage(source, msg) {
2877
+ const { requestId } = msg;
2878
+ if (typeof requestId !== 'number') return;
2879
+ const reply = (extra) => {
2880
+ try {
2881
+ source.postMessage({
2882
+ __bitpub: true,
2883
+ type: 'bitpub.identity.result',
2884
+ requestId,
2885
+ ...extra,
2886
+ }, '*');
2887
+ } catch (_) { /* iframe gone */ }
2888
+ };
2889
+ try {
2890
+ const r = await fetch('/bridge/identity');
2891
+ const body = await r.json();
2892
+ if (!r.ok) { reply({ ok: false, error: `http ${r.status}` }); return; }
2893
+ reply({ ok: true, owner: body.owner, domain: body.domain, private_prefix: body.private_prefix });
2894
+ } catch (err) {
2895
+ reply({ ok: false, error: err && err.message || 'fetch failed' });
2896
+ }
2897
+ }
2898
+
2899
+ function handleBridgeNavigateMessage(_source, msg) {
2900
+ // Apps can hand off to another slice in the running user's Browser.
2901
+ // We only allow bitpub:// addresses (no http:, no javascript:), and
2902
+ // we route through submitAddress() which already does all the
2903
+ // existing scope/path parsing.
2904
+ const hcu = String((msg && msg.hcu) || '').trim();
2905
+ if (!/^bitpub:\/\/[^\s]+$/.test(hcu)) return;
2906
+ try { submitAddress(hcu); } catch (_) { /* ignore */ }
2907
+ }
2908
+
2797
2909
  window.addEventListener('message', (e) => {
2798
2910
  const d = e.data;
2799
2911
  if (!d || typeof d !== 'object' || d.__bitpub !== true) return;
2800
2912
  if (d.type === 'bitpub.run') handleBridgeRunMessage(e.source, d);
2801
2913
  else if (d.type === 'bitpub.namespace.list') handleBridgeNamespaceListMessage(e.source, d);
2914
+ else if (d.type === 'bitpub.identity.me') handleBridgeIdentityMessage(e.source, d);
2915
+ else if (d.type === 'bitpub.navigate' || d.type === 'navigate') handleBridgeNavigateMessage(e.source, d);
2802
2916
  });
2803
2917
 
2804
2918
  function renderWriteGuide(sl) {