cookbook-bridge 0.1.10 → 0.1.11

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/README.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  ## Changelog
4
4
 
5
+ **0.1.11** (2026-09-02)
6
+ - The Bridge has a home: `~/.cookbook/config.json` (file 0600, folder 0700) for every command. `bridge.state.json`, `local.json` and `bridge.log` sit next to it. Before, the config lived next to `bridge.mjs`, so every `npx cookbook-bridge@latest` landed in a fresh cache folder and lost it. A config found next to `bridge.mjs` is copied to the home once (the old file stays) and the move is announced in one line. `--config <path>` and `COOKBOOK_CONFIG` still win.
7
+ - `connect` runs the Bridge right after the approval (pass `--no-run` to stop at "connected"). With no agent CLI found it prints the doctor instead.
8
+ - Every hint is a command that works where you are: `npx cookbook-bridge@latest <cmd>` from an npm install, `node bridge/bridge.mjs <cmd>` from a tarball.
9
+ - `doctor` gained rows for the config home (path, exists, mode), other Bridge processes on this machine (pids and config paths), Bridge Local (does the port answer), and whether the local files are behind the app deploy, with the same update line the running Bridge prints. `connect` prints that line too.
10
+ - Visiting agents can read `~/.cookbook/bridge.state.json` as a projection; `~/.cookbook/local.json` is never readable, like the desktop app's copy.
11
+
5
12
  **0.1.10** (2026-09-02)
6
13
  - Works on every Node from 18 up. Before, on Node older than 22.18 every command exited silently, doing nothing.
7
14
  - The npm package and the desktop app now ship every runtime file (realtime, sessions, synthesis, the approval relay, the hook reporter). Install-time crashes on import are gone, and a test now fails the build if a file is left out.
@@ -37,49 +44,49 @@ Trust model: your Cookbook's `/security` page.
37
44
  ## Quick start (2 minutes)
38
45
 
39
46
  ```bash
40
- npx cookbook-bridge@latest connect # one-time: ONE approval connects the Bridge AND every
41
- # installed agent CLI (claude, codex, agy, openclaw),
42
- # each with its own attributed token
47
+ npx cookbook-bridge@latest connect # ONE approval connects the Bridge AND every installed
48
+ # agent CLI (claude, codex, agy, openclaw), each with its
49
+ # own attributed token, then RUNS the Bridge. Leave it open.
50
+ ```
51
+
52
+ That is the whole setup. Later:
53
+
54
+ ```bash
55
+ npx cookbook-bridge@latest # run it again (config is remembered in ~/.cookbook)
43
56
  npx cookbook-bridge@latest doctor # preflight: checks every prerequisite, with exact fixes
44
- npx cookbook-bridge@latest # run it (leave it running)
45
57
  ```
46
58
 
47
59
  Always `@latest`: bare `npx cookbook-bridge` happily runs a weeks-old cached copy
48
- that predates subcommands you need (`host` shipped in 0.1.1).
60
+ that predates subcommands you need (`host` shipped in 0.1.1). Your config is not in
61
+ that cache, so `@latest` never loses it.
49
62
 
50
63
  Node 18+. No dependencies, nothing to configure by hand: `connect` writes
51
- `config.json` for you and never prints or stores a secret you have to copy.
64
+ `~/.cookbook/config.json` for you and never prints or stores a secret you have to copy.
65
+ Pass `--no-run` to stop at "connected" without starting the Bridge.
52
66
 
53
67
  <details>
54
68
  <summary>Prefer no package manager? Download the tarball instead.</summary>
55
69
 
56
70
  ```bash
57
71
  curl -fsSL https://cookbook.team/api/bridge/download | tar xz
58
- node bridge/bridge.mjs connect
72
+ node bridge/bridge.mjs connect # same flow: one approval, then it runs
59
73
  ```
60
74
 
61
- The commands below are written for this layout (`node bridge/bridge.mjs <command>`);
62
- with the npm install, every one of them is `npx cookbook-bridge <command>`.
75
+ From a tarball every command is `node bridge/bridge.mjs <command>` (run it from the
76
+ folder you unpacked, the one that contains `bridge/`); with the npm install it is
77
+ `npx cookbook-bridge@latest <command>`. The Bridge knows which layout it runs from and
78
+ prints the right one in every hint. The config lives in `~/.cookbook` either way.
63
79
 
64
80
  </details>
65
81
 
66
- ```bash
67
- # from the folder you unpacked (it contains bridge/)
68
- node bridge/bridge.mjs connect-agents # one-time: ONE approval connects the Bridge
69
- # AND every installed agent CLI (claude, agy, codex)
70
- # to Cookbook, each with its own attributed token
71
- node bridge/bridge.mjs doctor # preflight: checks EVERY prerequisite, prints
72
- # the exact fix for anything that's missing
73
- node bridge/bridge.mjs # run it (leave it running)
74
- ```
75
-
76
- No hand-pasting tokens: `connect-agents` is the intended path (device flow, like a TV
77
- app; the code expires in ~10 minutes — just re-run it if it lapses). One browser click
78
- authorizes the Bridge **and** mints a named token per detected agent CLI — the name is
79
- the attribution label ("Claude · via you") — then configures each CLI via its own
80
- `mcp add`. Prefer just the Bridge? `login` does the device flow without touching your
81
- CLIs. Fully manual: copy `config.example.json` → `config.json` and paste a token from
82
- your Cookbook **Account → Tokens** page.
82
+ No hand-pasting tokens: `connect` is the intended path (device flow, like a TV app; the
83
+ code expires in ~10 minutes, just re-run it if it lapses). One browser click authorizes
84
+ the Bridge **and** mints a named token per detected agent CLI (the name is the
85
+ attribution label, "Claude · via you"), then configures each CLI via its own `mcp add`.
86
+ `connect-agents` is the same command under its original name. Prefer just the Bridge?
87
+ `login` does the device flow without touching your CLIs. Fully manual: copy
88
+ `config.example.json` to `~/.cookbook/config.json` and paste a token from your Cookbook
89
+ **Account → Tokens** page.
83
90
 
84
91
  Requires **Node 18+** (built-in `fetch`, no npm install) and at least one agent CLI
85
92
  installed and logged in (`claude`, `agy` — the Antigravity CLI for Gemini — or the Codex app). Each agent must also be
@@ -88,15 +95,33 @@ you exactly which parts are ready and how to fix the rest.
88
95
 
89
96
  ## Commands
90
97
 
98
+ Written as `npx cookbook-bridge@latest <command>`; from a tarball, `node bridge/bridge.mjs <command>`.
99
+
91
100
  | Command | What it does |
92
101
  |---|---|
93
- | `node bridge.mjs` | Run the Bridge (uses `config.json` next to it; or pass a path) |
94
- | `node bridge.mjs connect-agents` | One approval connects the Bridge + every installed agent CLI (attributed tokens) |
95
- | `node bridge.mjs login` | Device-flow auth for the Bridge only, writes your config |
96
- | `node bridge.mjs doctor` | Preflight every prerequisite with exact fixes |
97
- | `node bridge.mjs status` | Liveness + agent readiness |
98
- | `node bridge.mjs update` | Update the Bridge to match the app (see Self-updating) |
99
- | `cookbook-bridge host` | Open the door: let an agent someone else runs help you set this machine up, inside a grant you approve. `--off` closes it. |
102
+ | (none) | Run the Bridge (uses `~/.cookbook/config.json`; or pass a path) |
103
+ | `connect` | One approval connects the Bridge + every installed agent CLI (attributed tokens), then runs the Bridge. `--no-run` stops at connected. `connect-agents` is the same command. |
104
+ | `login` | Device-flow auth for the Bridge only, writes your config |
105
+ | `doctor` | Preflight every prerequisite with exact fixes: config home, token, agents, other Bridge processes, Bridge Local, and whether the files are behind the app |
106
+ | `status` | Liveness + agent readiness |
107
+ | `update` | Update the Bridge to match the app (see Self-updating) |
108
+ | `host` | Open the door: let an agent someone else runs help you set this machine up, inside a grant you approve. `--off` closes it. |
109
+ | `connectors` | Survey MCP connectors across Claude, Codex and Gemini; `sync` gives every agent the same tools; `approve <name>` installs a team stdio connector |
110
+
111
+ ## Where things live (the config home)
112
+
113
+ Every command resolves the config the same way, first match wins:
114
+
115
+ 1. `--config <path>` (or, for the run command, a positional path)
116
+ 2. `COOKBOOK_CONFIG` in the environment
117
+ 3. `~/.cookbook/config.json` (folder 0700, file 0600)
118
+ 4. A `config.json` next to `bridge.mjs`, only when 3 does not exist yet. It is copied to
119
+ the home once, the old file is left in place, and one line says where the config
120
+ now lives (pre-0.1.11 installs kept it next to the code).
121
+
122
+ `bridge.state.json` (attempt counters), `local.json` (the Bridge Local loopback port and
123
+ token) and `bridge.log` sit in the same folder as whichever config is in use. The desktop
124
+ app passes its own `--config` path and keeps its files in its data folder.
100
125
 
101
126
  ## Self-updating
102
127
 
@@ -104,8 +129,11 @@ you exactly which parts are ready and how to fix the rest.
104
129
  to the deploy's manifest (`/api/bridge/manifest`) and — with `"autoUpdate": true`, the
105
130
  default — replaces them (every file hash-verified first, originals kept in
106
131
  a per-version `bridge.backup/<deploy>/` dir, your `config.json` and token never touched) and restarts itself.
107
- Set `"autoUpdate": false` to pin your version; `node bridge.mjs update` updates manually
108
- and works even from a broken install.
132
+ Set `"autoUpdate": false` to pin your version; `node bridge/bridge.mjs update` updates
133
+ manually and works even from a broken install. An npm install is updated by npm: re-run
134
+ `npx cookbook-bridge@latest`, and your config in `~/.cookbook` comes along untouched.
135
+ `doctor` and `connect` run the same comparison and print the same update line when the
136
+ local files are behind.
109
137
 
110
138
  ## Agents (config.json)
111
139
 
package/bridge.mjs CHANGED
@@ -13,15 +13,18 @@
13
13
  * runs their own Bridge for their own agents — this is the seed of the shippable
14
14
  * "Cookbook Bridge" client).
15
15
  *
16
- * Run: node bridge/bridge.mjs (uses bridge/config.json)
16
+ * Run: node bridge/bridge.mjs (uses ~/.cookbook/config.json)
17
17
  * node bridge/bridge.mjs ./my.json (explicit config path)
18
- * node bridge/bridge.mjs login (one-click connect, RFC 8628)
18
+ * node bridge/bridge.mjs connect (one approval connects + runs)
19
19
  * node bridge/bridge.mjs status (liveness + agent readiness)
20
20
  * node bridge/bridge.mjs doctor (preflight: check every prerequisite,
21
21
  * print the exact fix for each ✗)
22
22
  *
23
23
  * "login", "status", and "doctor" are reserved first-args; pass a config path to
24
24
  * those subcommands with --config <path>. Plain `node bridge.mjs [path]` is unchanged.
25
+ * Config home (0.1.11): explicit path/--config, else COOKBOOK_CONFIG, else
26
+ * ~/.cookbook/config.json; a legacy config next to this file is copied there once.
27
+ * bridge.state.json, local.json and bridge.log sit next to whichever config is used.
25
28
  *
26
29
  * Node built-ins only. No dependencies.
27
30
  */
@@ -50,6 +53,10 @@ import { planFromStreamLine, notePlan, planLine } from "./plan.mjs";
50
53
  // the updater when these were static imports.
51
54
  import { deriveWakeTopic, wakeSocketSupported, connectWakeSocket } from "./realtime.mjs";
52
55
  import { createSessionReporter } from "./sessions.mjs";
56
+ import { resolveAgentForTask } from "./chef.mjs";
57
+ // The config home + command phrasing live in update.mjs (node built-ins only), so the
58
+ // broken-install `update` path and every other command agree on both.
59
+ import { locateConfig, cli, updateLine, configHome } from "./update.mjs";
53
60
  let listWorkspaces, listTasks, listOpenWork, getTask, threadResumeContext, completeTaskApi, resolveDelegation, reportTaskUsage, reportTaskProgress, volunteerClaim, dispatchClaim, abandonTask, recallMemories, recallAcrossWorkspaces, creditRecall, getVolunteerSettings, agentsQuery;
54
61
  let agentEnv, checkGeminiVersion, isGeminiCommand, GEMINI_MIN_VERSION, checkAgyVersion, isAgyCommand, AGY_MIN_VERSION, withCookbookMcp, isClaudeCommand, withApprovalRelay, materializeMcpConfig;
55
62
  let extractUsage, displayText;
@@ -150,22 +157,20 @@ function resolveBin(cmd) {
150
157
  }
151
158
 
152
159
  /** Pick a config path from a subcommand's args: `--config <path>`, else first
153
- * positional, else the default config.json next to this file. */
154
- function configPathFromArgs(args, fallback) {
155
- const i = args.indexOf("--config");
156
- if (i >= 0 && args[i + 1]) return path.resolve(args[i + 1]);
157
- const positional = (args || []).find((a) => a && !a.startsWith("-"));
158
- return positional ? path.resolve(positional) : fallback;
160
+ * positional, else the config home (update.mjs locateConfig: COOKBOOK_CONFIG,
161
+ * ~/.cookbook/config.json, one-time copy of a legacy config next to this file). */
162
+ function configPathFromArgs(args, { positional = true } = {}) {
163
+ return locateConfig(args || [], { here: HERE, positional, log: (m) => console.error(m) });
159
164
  }
160
165
 
161
166
  /** Where the running Bridge's config lives (Bridge Local writes local.json next to it). */
162
167
  let CONFIG_PATH = null;
163
168
 
164
169
  function loadConfig() {
165
- const p = process.argv[2] ? path.resolve(process.argv[2]) : path.join(HERE, "config.json");
170
+ const p = configPathFromArgs(process.argv.slice(2));
166
171
  CONFIG_PATH = p;
167
172
  if (!fs.existsSync(p)) {
168
- console.error(`No config at ${p}.\nEasiest: run \`node bridge/bridge.mjs login\` — one-click connect, no token to paste.\n(Manual alternative: copy bridge/config.example.json → bridge/config.json and add a token from Account → Tokens.)`);
173
+ console.error(`No config at ${p}.\nEasiest: run \`${cli("connect")}\`: one approval, no token to paste, and the Bridge starts right after.\n(Manual alternative: copy config.example.json to ${p} and add a token from Account > Tokens.)`);
169
174
  process.exit(1);
170
175
  }
171
176
  const cfg = JSON.parse(fs.readFileSync(p, "utf8"));
@@ -609,7 +614,7 @@ async function runAgent(cfg, agent, prompt, onProgress, retry = null, taskCtx =
609
614
  const codexToken = agent.token || cfg.codexToken || cfg.token;
610
615
  if (!agent.token && !cfg.codexToken && !warnedCodexToken) {
611
616
  warnedCodexToken = true;
612
- log("! Codex has no agent token — running under your Bridge token. Run `cookbook-bridge connect-agents` so its work reads \"Codex · via you\".");
617
+ log(`! Codex has no agent token, so it runs under your Bridge token. Run \`${cli("connect")}\` so its work reads "Codex · via you".`);
613
618
  }
614
619
  return runCodexTask(agent, prompt, cfg.taskTimeoutSeconds, codexToken, env, onProgress, { threadKey, log, model });
615
620
  }
@@ -690,7 +695,7 @@ function failureHint(result) {
690
695
  if (infra.includes("not logged in") || infra.includes("please log in"))
691
696
  return "the agent CLI isn't logged in → run `claude auth login`";
692
697
  if (infra.includes("no mcp") || infra.includes("requires authentication"))
693
- return "the agent can't reach the Cookbook MCP → run `node bridge/bridge.mjs doctor`";
698
+ return `the agent can't reach the Cookbook MCP → run \`${cli("doctor")}\``;
694
699
  if (infra.includes("not allowed") || infra.includes("allowedtools"))
695
700
  return "a tool was blocked → check `allowedTools` matches the agent's MCP server name (CLI-added = mcp__cookbook__*)";
696
701
  if (typeof code === "number" && code !== 0) return `agent exited ${code}${tail ? `: ${tail}` : ""}`;
@@ -744,11 +749,17 @@ function agentHeld(agent) {
744
749
  // re-exec, manual bounce) reset the counters — and the Bridge resumes its own
745
750
  // claimed tasks, so a restart granted every stuck task a fresh pair of attempts
746
751
  // (2026-07-13: a doomed goal would have re-burned 6x900s across restarts).
747
- // Persisted next to config; trimmed so it can't grow unbounded.
748
- const STATE_PATH = path.join(HERE, "bridge.state.json");
752
+ // Persisted next to the config (the config home); trimmed so it can't grow unbounded.
753
+ // A pre-0.1.11 state file next to bridge.mjs is read once when the new one is absent.
754
+ function statePath() {
755
+ return path.join(path.dirname(CONFIG_PATH || path.join(HERE, "config.json")), "bridge.state.json");
756
+ }
749
757
  function loadRunState() {
750
758
  try {
751
- const raw = JSON.parse(fs.readFileSync(STATE_PATH, "utf8"));
759
+ const p = statePath();
760
+ const legacy = path.join(HERE, "bridge.state.json");
761
+ const src = fs.existsSync(p) ? p : legacy;
762
+ const raw = JSON.parse(fs.readFileSync(src, "utf8"));
752
763
  for (const [id, n] of Object.entries(raw.attempts ?? {})) attempts.set(id, Number(n) || 0);
753
764
  for (const id of raw.givenUp ?? []) givenUp.add(id);
754
765
  for (const [id, ctx] of Object.entries(raw.retryCtx ?? {})) retryCtx.set(id, ctx);
@@ -760,7 +771,7 @@ function saveRunState() {
760
771
  const attEntries = [...attempts.entries()].slice(-500);
761
772
  const given = [...givenUp].slice(-500);
762
773
  const retries = [...retryCtx.entries()].slice(-200);
763
- fs.writeFileSync(STATE_PATH, JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries) }));
774
+ fs.writeFileSync(statePath(), JSON.stringify({ attempts: Object.fromEntries(attEntries), givenUp: given, retryCtx: Object.fromEntries(retries) }));
764
775
  } catch { /* best-effort — never let state persistence break a run */ }
765
776
  }
766
777
 
@@ -1473,7 +1484,7 @@ function warnIfTokenExpiring(expiresAt) {
1473
1484
  const today = new Date().toISOString().slice(0, 10);
1474
1485
  if (lastExpiryWarnDay === today) return;
1475
1486
  lastExpiryWarnDay = today;
1476
- log(`! this Bridge's token expires in ${Math.max(daysLeft, 0)} day(s) — re-run \`node bridge/bridge.mjs login\` before it does.`);
1487
+ log(`! this Bridge's token expires in ${Math.max(daysLeft, 0)} day(s). Re-run \`${cli("connect")}\` before it does.`);
1477
1488
  }
1478
1489
 
1479
1490
  let wsCursor = 0;
@@ -1817,7 +1828,10 @@ async function dispatchWorkInner(cfg, work, warmHints) {
1817
1828
  if ((t.assigned_to || "").toLowerCase() === "goal") continue;
1818
1829
  if (inFlight.has(t.id) || givenUp.has(t.id)) continue;
1819
1830
  if ((attempts.get(t.id) ?? 0) >= cfg.maxAttempts) continue;
1820
- const agent = agentFor(cfg, t.assigned_to);
1831
+ // Round two: no configured agent for "Chef" + a support task addressed to Chef +
1832
+ // chef-persona.md shipped next to this file = a Chef synthesized from this
1833
+ // Bridge's own Claude (bridge/chef.mjs). Configured agents always win.
1834
+ const agent = agentFor(cfg, t.assigned_to) ?? resolveAgentForTask(cfg.agents, t, cfg);
1821
1835
  if (!agent) continue;
1822
1836
  if (agentHeld(agent)) continue;
1823
1837
  if (!allowedByPolicy(cfg, agent, t)) continue;
@@ -1889,7 +1903,10 @@ async function pollOnce(cfg, onlyWorkspaceIds = null) {
1889
1903
  continue;
1890
1904
  }
1891
1905
 
1892
- const agent = agentFor(cfg, t.assigned_to);
1906
+ // Round two: no configured agent for "Chef" + a support task addressed to Chef +
1907
+ // chef-persona.md shipped next to this file = a Chef synthesized from this
1908
+ // Bridge's own Claude (bridge/chef.mjs). Configured agents always win.
1909
+ const agent = agentFor(cfg, t.assigned_to) ?? resolveAgentForTask(cfg.agents, t, cfg);
1893
1910
  if (!agent) continue; // no local agent handles this assignee
1894
1911
  if (agentHeld(agent)) continue; // plan window closed: wait for the reset, keep the attempt
1895
1912
 
@@ -2002,16 +2019,12 @@ async function selfUpdate(cfg, { reexec }) {
2002
2019
  // Say it once per process: drift should be visible, not noisy, and never silent.
2003
2020
  if (!updateNagged) {
2004
2021
  updateNagged = true;
2005
- log(
2006
- channel === "app"
2007
- ? `⬆ A newer Bridge ships with the app (deploy ${check.version}). The Cookbook app manages this copy — update the app to pick it up.`
2008
- : `⬆ A newer Bridge is available (deploy ${check.version}). Update it with: npx cookbook-bridge@latest connect`,
2009
- );
2022
+ log(`⬆ ${updateLine(check.version, { here: HERE, desktop: channel === "app" })}`);
2010
2023
  }
2011
2024
  return;
2012
2025
  }
2013
2026
  if (cfg.autoUpdate === false) {
2014
- log(`⬆ Bridge update available (deploy ${check.version}; ${check.changed.length} file(s) changed) — run \`node bridge.mjs update\`. (autoUpdate is off.)`);
2027
+ log(`⬆ ${updateLine(check.version, { here: HERE, desktop: false })} (${check.changed.length} file(s) changed; autoUpdate is off.)`);
2015
2028
  return;
2016
2029
  }
2017
2030
  try {
@@ -2061,7 +2074,7 @@ async function main() {
2061
2074
  ? (dflt.bin || "openclaw")
2062
2075
  : null;
2063
2076
  if (!resolveBin(dfltBin)) {
2064
- log(`! default agent "${dflt.name}" isn't installed/on PATH — "any"-assigned tasks will fail. Run \`node bridge.mjs doctor\`.`);
2077
+ log(`! default agent "${dflt.name}" isn't installed/on PATH, so "any"-assigned tasks will fail. Run \`${cli("doctor")}\`.`);
2065
2078
  }
2066
2079
  }
2067
2080
 
@@ -2196,9 +2209,9 @@ async function main() {
2196
2209
  }
2197
2210
  {
2198
2211
  const mode = hostingMode(cfg);
2199
- if (mode === "always") log("⌂ Hosting is ON — an agent you invite can run granted checks on this machine. You'll see every step; `cookbook-bridge host --off` closes the door.");
2200
- else if (mode === "grants") log("⌂ Hosting: grants you approve in Cookbook run here (every change still waits for your click). `cookbook-bridge host --off` refuses all.");
2201
- else log("⌂ Hosting is OFF — no visiting agent can act on this machine. `cookbook-bridge host` opens it.");
2212
+ if (mode === "always") log(`⌂ Hosting is ON: an agent you invite can run granted checks on this machine. You'll see every step; \`${cli("host --off")}\` closes the door.`);
2213
+ else if (mode === "grants") log(`⌂ Hosting: grants you approve in Cookbook run here (every change still waits for your click). \`${cli("host --off")}\` refuses all.`);
2214
+ else log(`⌂ Hosting is OFF: no visiting agent can act on this machine. \`${cli("host")}\` opens it.`);
2202
2215
  if (mode !== "off") await pollHands(cfg);
2203
2216
  }
2204
2217
  // Signals are handled by installShutdown (registered before connecting).
@@ -2207,7 +2220,7 @@ async function main() {
2207
2220
  lastRunError = e.message;
2208
2221
  if (IS_DESKTOP && localServer) {
2209
2222
  log(`! Not connected to Cookbook yet: ${e.message}`);
2210
- log(` Fix it in the app (Connect your agents), or run \`node bridge.mjs login\`. The control API stays up so you can.`);
2223
+ log(` Fix it in the app (Connect your agents), or run \`${cli("connect")}\`. The control API stays up so you can.`);
2211
2224
  } else {
2212
2225
  console.error(`\nCouldn't connect to Cookbook: ${e.message}`);
2213
2226
  process.exit(1);
@@ -2278,7 +2291,7 @@ async function main() {
2278
2291
  await new Promise((r) => setTimeout(r, Math.max(cfg.pollSeconds * 1000, 15_000)));
2279
2292
  } else {
2280
2293
  log("✗ Cookbook has rejected this token 5 polls in a row — it was likely revoked (a new login replaces old tokens) or expired.");
2281
- log(" Fix: node bridge/bridge.mjs login (then restart the Bridge)");
2294
+ log(` Fix: ${cli("connect")} (reconnects and starts the Bridge)`);
2282
2295
  process.exit(1);
2283
2296
  }
2284
2297
  }
@@ -2373,22 +2386,32 @@ async function doctorReport(args) {
2373
2386
  if (major >= 18) ok(`Node ${process.version}`);
2374
2387
  else bad(`Node ${process.version} is too old (need ≥18)`, "install Node 18+ (e.g. `brew install node`)");
2375
2388
 
2376
- // 2. Config present + filled in
2377
- const cfgPath = configPathFromArgs(args, path.join(HERE, "config.json"));
2389
+ // 2. Config home (0.1.11): where the config lives, whether it exists, how tight it is.
2390
+ const cfgPath = configPathFromArgs(args);
2391
+ const home = configHome();
2392
+ const modeOf = (p) => { try { return (fs.statSync(p).mode & 0o777).toString(8); } catch { return null; } };
2393
+ {
2394
+ const homeMode = modeOf(home);
2395
+ if (homeMode === null) ok(`Config home ${home} (not created yet; \`${cli("connect")}\` creates it)`);
2396
+ else if (process.platform === "win32" || homeMode === "700") ok(`Config home ${home} (exists, mode ${homeMode})`);
2397
+ else warn(`Config home ${home} is mode ${homeMode}; it holds tokens and should be owner-only`, `chmod 700 "${home}"`);
2398
+ }
2378
2399
  let cfg = null;
2379
2400
  if (!fs.existsSync(cfgPath)) {
2380
- bad(`No config at ${cfgPath}`, "run `node bridge/bridge.mjs login` (one-click connect) — or copy config.example.json → config.json and add a token");
2401
+ bad(`No config at ${cfgPath}`, `run \`${cli("connect")}\` (one approval; it writes the config and starts the Bridge)`);
2381
2402
  } else {
2382
2403
  try {
2383
2404
  cfg = JSON.parse(fs.readFileSync(cfgPath, "utf8"));
2384
2405
  cfg.cookbookUrl = (cfg.cookbookUrl || "").replace(/\/$/, "");
2385
2406
  cfg.agents = (cfg.agents ?? []).filter((a) => a.enabled !== false);
2386
- for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
2407
+ for (const a of cfg.agents) a.cookbookUrl = cfg.cookbookUrl; // for per-run MCP pinning (spawnAgent)
2387
2408
  if (!cfg.cookbookUrl || !cfg.token || String(cfg.token).startsWith("PASTE")) {
2388
- bad("Config is missing cookbookUrl or a real token", "set both in config.json (token from your Cookbook → Tokens page)");
2409
+ bad("Config is missing cookbookUrl or a real token", `run \`${cli("connect")}\`, or set both in ${cfgPath} (token from your Cookbook > Tokens page)`);
2389
2410
  cfg = null;
2390
2411
  } else {
2391
- ok(`Config ${cfgPath}`);
2412
+ const m = modeOf(cfgPath);
2413
+ if (process.platform === "win32" || m === "600") ok(`Config ${cfgPath} (mode ${m ?? "n/a"})`);
2414
+ else warn(`Config ${cfgPath} is mode ${m}; it carries your token and should be owner-only`, `chmod 600 "${cfgPath}"`);
2392
2415
  }
2393
2416
  } catch (e) {
2394
2417
  bad(`Config isn't valid JSON: ${e.message}`);
@@ -2396,6 +2419,50 @@ async function doctorReport(args) {
2396
2419
  }
2397
2420
  }
2398
2421
 
2422
+ // 2b. Another Bridge on this machine? Two on one config fight over the same token
2423
+ // (a `connect` revokes the other's); one on a different config is the classic
2424
+ // "I connected but a stale Bridge is still running" trap.
2425
+ try {
2426
+ const { scanBridgeProcesses } = await import("./device.mjs");
2427
+ const others = scanBridgeProcesses({ selfPid: process.pid });
2428
+ if (others === null) ok("Another Bridge process on this machine: could not scan (ps/tasklist unavailable)");
2429
+ else if (!others.length) ok("No other Bridge process on this machine");
2430
+ else {
2431
+ const desc = others.map((o) => `pid ${o.pid}${o.configPath ? ` (config ${o.configPath})` : o.unknownCommand ? " (node.exe; command line not visible)" : " (default config)"}`).join(", ");
2432
+ warn(`Another Bridge process on this machine: ${desc}`, `stop it if you meant to run only one (kill ${others.map((o) => o.pid).join(" ")}); two Bridges on one config fight over the same token`);
2433
+ }
2434
+ } catch (e) {
2435
+ ok(`Another Bridge process on this machine: could not scan (${e.message})`);
2436
+ }
2437
+
2438
+ // 2c. Bridge Local: the loopback control API a running Bridge writes local.json for.
2439
+ {
2440
+ const localPath = path.join(path.dirname(cfgPath), "local.json");
2441
+ let local = null;
2442
+ try { local = JSON.parse(fs.readFileSync(localPath, "utf8")); } catch { /* not running */ }
2443
+ if (!local || !local.port) ok(`Bridge Local: not running (no ${localPath})`);
2444
+ else {
2445
+ let answered = false;
2446
+ try {
2447
+ const res = await fetch(`http://127.0.0.1:${local.port}/status`, { headers: { "X-Bridge-Token": String(local.token || "") }, signal: AbortSignal.timeout(2000) });
2448
+ answered = res.ok;
2449
+ } catch { answered = false; }
2450
+ if (answered) ok(`Bridge Local answering on 127.0.0.1:${local.port} (pid ${local.pid ?? "?"})`);
2451
+ else warn(`Bridge Local: ${localPath} says port ${local.port} (pid ${local.pid ?? "?"}) but nothing answers there`, `that Bridge is gone or stuck; start one with \`${cli()}\` (it rewrites local.json)`);
2452
+ }
2453
+ }
2454
+
2455
+ // 2d. Staleness: the same manifest comparison startup does, with the same fix line.
2456
+ if (cfg && cfg.cookbookUrl && checkForUpdate) {
2457
+ try {
2458
+ const check = await checkForUpdate({ cookbookUrl: cfg.cookbookUrl }, HERE);
2459
+ if (check.changed.length === 0) ok(`Bridge files match the app deploy (${check.version})`);
2460
+ else warn(`Bridge files are behind the app deploy (${check.version}; ${check.changed.length} file(s) differ)`, updateLine(check.version, { here: HERE, desktop: IS_DESKTOP }));
2461
+ } catch (e) {
2462
+ warn(`Couldn't compare the Bridge to the app deploy (${e.message})`, "offline is fine; the running Bridge re-checks every 6 hours");
2463
+ }
2464
+ }
2465
+
2399
2466
  if (cfg) {
2400
2467
  ensureAgentPath();
2401
2468
 
@@ -2404,7 +2471,7 @@ async function doctorReport(args) {
2404
2471
  const ws = await listWorkspaces(cfg);
2405
2472
  ok(`Cookbook token works — ${ws.length} workspace(s) visible`);
2406
2473
  } catch (e) {
2407
- bad(`Cookbook token rejected: ${e.message}`, "regenerate a token on your Cookbook → Tokens page and update config.json");
2474
+ bad(`Cookbook token rejected: ${e.message}`, `run \`${cli("connect")}\` to mint a fresh one (or paste a token from your Cookbook > Tokens page into ${cfgPath})`);
2408
2475
  }
2409
2476
 
2410
2477
  // 4. Default agent is one of the enabled agents
@@ -2468,14 +2535,14 @@ async function doctorReport(args) {
2468
2535
  ok(`${agent.name}: Cookbook MCP configured (${agyMcpPath})`);
2469
2536
  } else if (srv) {
2470
2537
  warn(`${agent.name}: MCP config points at ${srv.serverUrl || "(no url)"}`,
2471
- `expected ${cfg.cookbookUrl}/api/mcp — re-run \`node bridge/bridge.mjs connect-agents\``);
2538
+ `expected ${cfg.cookbookUrl}/api/mcp; re-run \`${cli("connect")}\``);
2472
2539
  } else {
2473
2540
  bad(`${agent.name}: no 'cookbook' server in ${agyMcpPath}`,
2474
- "run `node bridge/bridge.mjs connect-agents` (agy has no `mcp add`; the Bridge writes this file)");
2541
+ `run \`${cli("connect")}\` (agy has no \`mcp add\`; the Bridge writes this file)`);
2475
2542
  }
2476
2543
  } catch {
2477
2544
  bad(`${agent.name}: no agy MCP config at ${agyMcpPath}`,
2478
- "run `node bridge/bridge.mjs connect-agents` (agy has no `mcp add`; the Bridge writes this file)");
2545
+ `run \`${cli("connect")}\` (agy has no \`mcp add\`; the Bridge writes this file)`);
2479
2546
  }
2480
2547
  if (!fs.existsSync(path.join(process.env.HOME || os.homedir(), ".gemini", "oauth_creds.json"))) {
2481
2548
  warn(`${agent.name}: no Google login found (~/.gemini/oauth_creds.json)`,
@@ -2486,7 +2553,7 @@ async function doctorReport(args) {
2486
2553
  if (isClaudeCommand && isClaudeCommand(agent.command)) {
2487
2554
  if (agent.token) ok(`${agent.name}: runs carry their own Cookbook connection (per-agent token) — identity is this Bridge's member`);
2488
2555
  else warn(`${agent.name}: no per-agent token — runs use the claude CLI's OWN Cookbook login, which may be a different account and inherits stale claude.ai connectors`,
2489
- "run `cookbook-bridge connect` (mints a token for this agent) or add \"token\" to this agent in config.json");
2556
+ `run \`${cli("connect")}\` (mints a token for this agent) or add "token" to this agent in ${cfgPath}`);
2490
2557
  }
2491
2558
  if ((agent.command || []).join(" ").includes("mcp__claude_ai_Cookbook__")) {
2492
2559
  warn(`${agent.name}: allowedTools uses mcp__claude_ai_Cookbook__* — a CLI-added server is usually mcp__cookbook__*`,
@@ -2579,13 +2646,12 @@ if (!IS_MAIN) {
2579
2646
  import("./connectors.mjs")
2580
2647
  .then(async (m) => {
2581
2648
  const args = process.argv.slice(3);
2582
- const ci = args.indexOf("--config");
2583
- const cfgPath = ci >= 0 && args[ci + 1] ? path.resolve(args[ci + 1]) : path.join(HERE, "config.json");
2649
+ const cfgPath = configPathFromArgs(args, { positional: false });
2584
2650
  if (args[0] === "approve") {
2585
2651
  // A team STDIO connector runs a command on this machine; it is recorded on
2586
2652
  // sync and written only after this explicit approval (connectors.mjs).
2587
2653
  const name = args[1];
2588
- if (!name) { console.error("Usage: cookbook-bridge connectors approve <name>"); process.exit(1); }
2654
+ if (!name) { console.error(`Usage: ${cli("connectors approve <name>")}`); process.exit(1); }
2589
2655
  const lines = m.approveConnector(name, { cfgPath });
2590
2656
  console.log(`\n Approved "${name}". Written to your agent CLIs:`);
2591
2657
  for (const l of lines) console.log(l);
@@ -2597,7 +2663,7 @@ if (!IS_MAIN) {
2597
2663
  if (!waiting.length) return console.log("\n No team connectors are waiting for your approval.\n");
2598
2664
  console.log("\n Team connectors waiting for your approval (each runs a command on this machine):");
2599
2665
  for (const [n, e] of waiting) console.log(` ${n.padEnd(22)} ${[e.command, ...(e.args ?? [])].join(" ").slice(0, 80)}`);
2600
- console.log("\n Approve one with: cookbook-bridge connectors approve <name>\n");
2666
+ console.log(`\n Approve one with: ${cli("connectors approve <name>")}\n`);
2601
2667
  return;
2602
2668
  }
2603
2669
  const doSync = args.includes("sync");
@@ -2617,7 +2683,7 @@ if (!IS_MAIN) {
2617
2683
  if (!doSync) {
2618
2684
  const missing = rows.filter((r) => vendors.some((v) => !r.present[v])).length;
2619
2685
  console.log(`\n ${missing} connector(s) aren't on every agent.`);
2620
- console.log(" Run `node bridge/bridge.mjs connectors sync` to give every agent the same tools.\n");
2686
+ console.log(` Run \`${cli("connectors sync")}\` to give every agent the same tools.\n`);
2621
2687
  return;
2622
2688
  }
2623
2689
  const only = args.filter((a) => !a.startsWith("--") && a !== "sync");
@@ -2646,8 +2712,24 @@ if (!IS_MAIN) {
2646
2712
  } else if (sub === "connect" || sub === "connect-agents") {
2647
2713
  // `connect` is the documented first command (the connect page and the npm bin both
2648
2714
  // say it); `connect-agents` is the original name, kept working forever.
2715
+ // 0.1.11: after the approval the Bridge RUNS, right here, unless --no-run. Stopping
2716
+ // at "connected" was the single most confusing moment (nothing polls, and the
2717
+ // approval just revoked whatever older Bridge was polling).
2649
2718
  import("./device.mjs")
2650
- .then((m) => m.connectAgents(process.argv.slice(3)))
2719
+ .then(async (m) => {
2720
+ const args = process.argv.slice(3);
2721
+ const noRun = args.includes("--no-run");
2722
+ const r = await m.connectAgents(args.filter((a) => a !== "--no-run"), { willRun: !noRun });
2723
+ if (!r || !r.ok) {
2724
+ // Nothing to run (no agent CLI found): the doctor says what is missing and how to fix it.
2725
+ if (r && r.reason === "no-agents") await runDoctor(["--config", r.cfgPath]);
2726
+ return;
2727
+ }
2728
+ if (noRun || !r.startBridge) return;
2729
+ console.log("Connected. Running the Bridge now; leave this window open. Ctrl-C stops it.\n");
2730
+ process.argv = [process.argv[0], process.argv[1], r.cfgPath];
2731
+ await main();
2732
+ })
2651
2733
  .catch((e) => {
2652
2734
  console.error(e.message);
2653
2735
  process.exit(1);
@@ -2663,10 +2745,7 @@ if (!IS_MAIN) {
2663
2745
  // main() reads its config from argv[2]: carry `--config <path>` over, or
2664
2746
  // the Bridge that opens the door runs on a different config than `host`
2665
2747
  // just wrote.
2666
- const ci = hostArgs.indexOf("--config");
2667
- process.argv = ci >= 0 && hostArgs[ci + 1]
2668
- ? [process.argv[0], process.argv[1], path.resolve(hostArgs[ci + 1])]
2669
- : [process.argv[0], process.argv[1]];
2748
+ process.argv = [process.argv[0], process.argv[1], r.cfgPath || m.configPath(hostArgs)];
2670
2749
  await main();
2671
2750
  }
2672
2751
  })
@@ -2698,13 +2777,13 @@ if (!IS_MAIN) {
2698
2777
  // Config is read minimally here (no loadConfig: that treats argv[2] as a path and
2699
2778
  // demands a token — updates need only the cookbookUrl, both endpoints are public).
2700
2779
  const upd = await import("./update.mjs");
2701
- const cfgPath = configPathFromArgs(process.argv.slice(3), path.join(HERE, "config.json"));
2780
+ const cfgPath = configPathFromArgs(process.argv.slice(3));
2702
2781
  let cookbookUrl = "";
2703
2782
  try {
2704
2783
  cookbookUrl = String(JSON.parse(fs.readFileSync(cfgPath, "utf8")).cookbookUrl || "").replace(/\/$/, "");
2705
2784
  } catch { /* fall through to the error below */ }
2706
2785
  if (!cookbookUrl) {
2707
- console.error(`Can't read cookbookUrl from ${cfgPath} — pass --config <path> or fix config.json.`);
2786
+ console.error(`Can't read cookbookUrl from ${cfgPath}. Pass --config <path>, or run \`${cli("connect")}\` to write one.`);
2708
2787
  process.exit(1);
2709
2788
  }
2710
2789
  const cfg = { cookbookUrl };
@@ -0,0 +1,9 @@
1
+ You are Chef 👨🏿‍🍳, Cookbook's support agent. You live in the Ask Chef panel, bottom right of every Cookbook page, and you answer as a calm, direct teammate who has set Cookbook up a hundred times.
2
+
3
+ Voice: short sentences, plain words, no marketing. Lead with the answer, then one concrete next step. Say "I don't know" rather than invent a flag, a path, or a menu label. Never paste tokens, never ask for them.
4
+
5
+ What you know: Cookbook is the shared brain a team's AI agents plug into. Every agent action is attributed, receipted, remembered. Step 1 for anyone is to point an agent they already have (Claude, Claude Code, OpenClaw) at the MCP URL; nothing to install. Step 2, optional, is the Bridge (Cookbook Desktop or one command) for agents that work while they're away, local folders, and hands. For Claude users there is also "Claude in the cloud" under Account, then Agents, which needs no install.
6
+
7
+ How you work: the message you receive carries the rules for this conversation (a grant id, whether you may ask for machine access, and Cookbook Help notes that match the question). Follow those rules exactly; they override anything here. Use the Cookbook tools only. When the answer depends on the person's machine, ask for access the way the rules describe. Never send them to a terminal.
8
+
9
+ You are running on this person's own machine and subscription as their agent. You can see their workspaces. When you need to look at or change their setup, ask for a hands grant in the usual way; every action still waits for their click.
package/chef.mjs ADDED
@@ -0,0 +1,140 @@
1
+ /**
2
+ * LOCAL-FIRST CHEF (round two).
3
+ *
4
+ * Chef used to be exactly one agent on the founder's Mac: a "Chef" entry in dp's
5
+ * config, running claude with the persona file appended. Every outage on that one
6
+ * machine took Chef offline for everyone, every turn spent the founder's plan, and
7
+ * Chef could never see the asker's real workspaces (the identity trap).
8
+ *
9
+ * Now, when the person asking has a live Bridge that runs Claude, the SERVER scopes
10
+ * the support task to THEM (assigned_to "Chef", assigned_to_profile = the asker), and
11
+ * their own Bridge synthesizes a Chef from their own Claude agent: same binary, same
12
+ * login, same plan, plus the persona shipped next to this file. Nothing to configure.
13
+ * The founder-Mac Chef remains the fallback for people with no Bridge yet.
14
+ *
15
+ * Pure where possible: nothing here touches the filesystem on import, and the one
16
+ * existence check is injectable so the tests never need a real persona file.
17
+ */
18
+ import { existsSync } from "node:fs";
19
+ import path from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ /** The persona file shipped with the Bridge runtime, next to bridge.mjs. */
23
+ export const CHEF_PERSONA_FILE = "chef-persona.md";
24
+
25
+ /** The synthesized agent's name and the assignee it answers to. */
26
+ export const CHEF_NAME = "Chef";
27
+
28
+ /** The model a synthesized Chef runs on when the base command names none. */
29
+ export const CHEF_DEFAULT_MODEL = "sonnet";
30
+
31
+ const DEFAULT_ALLOWED_TOOLS = "mcp__cookbook__*";
32
+
33
+ /** Where the persona lives for THIS install: next to the module that asked. */
34
+ export function personaPathNextTo(moduleUrl = import.meta.url) {
35
+ return path.join(path.dirname(fileURLToPath(moduleUrl)), CHEF_PERSONA_FILE);
36
+ }
37
+
38
+ /** Is this configured agent a plain claude CLI runner we can wear the persona on? */
39
+ export function isClaudeAgent(agent) {
40
+ if (!agent || agent.enabled === false) return false;
41
+ if (agent.runner && agent.runner !== "cli" && agent.runner !== "claude") return false;
42
+ if (!Array.isArray(agent.command) || agent.command.length === 0) return false;
43
+ const bin = path.basename(String(agent.command[0])).toLowerCase();
44
+ return bin === "claude" || bin === "claude.exe";
45
+ }
46
+
47
+ /** Does a configured agent answer to this assignee? Mirrors bridge.mjs agentFor. */
48
+ export function matchesAssignee(agent, assignedTo) {
49
+ if (!agent || agent.enabled === false) return false;
50
+ const a = String(assignedTo ?? "").toLowerCase();
51
+ if (!a) return false;
52
+ return (agent.match ?? [agent.name]).some((m) => a.includes(String(m).toLowerCase()));
53
+ }
54
+
55
+ /**
56
+ * Synthesize the local Chef from the first enabled claude agent, or null when the
57
+ * Bridge manages no claude at all (nothing to wear the persona on).
58
+ *
59
+ * The command is the base command plus `--append-system-prompt-file <persona>`, and
60
+ * `--model sonnet` unless the base already pins a model. The base's allowedTools
61
+ * stands as-is (that prefix is the person's own MCP wiring, verified by doctor); a
62
+ * base with none gets mcp__cookbook__* so Chef can reach the Cookbook tools at all.
63
+ * Identity fields (token, cookbookUrl, approvalRelay) ride along so the run pins to
64
+ * exactly the same Cookbook connection the base Claude uses.
65
+ */
66
+ export function chefLocalAgent(cfg, agents, personaPath) {
67
+ const pool = Array.isArray(agents) ? agents : (cfg?.agents ?? []);
68
+ const base = pool.find(isClaudeAgent);
69
+ if (!base) return null;
70
+ const persona = personaPath || personaPathNextTo();
71
+ const command = [...base.command];
72
+ if (!command.includes("--allowedTools")) command.push("--allowedTools", DEFAULT_ALLOWED_TOOLS);
73
+ if (!command.includes("--model")) command.push("--model", CHEF_DEFAULT_MODEL);
74
+ if (!command.includes("--append-system-prompt-file")) command.push("--append-system-prompt-file", persona);
75
+ return {
76
+ name: CHEF_NAME,
77
+ match: ["chef"],
78
+ enabled: true,
79
+ command,
80
+ readOnly: false,
81
+ local: false,
82
+ volunteer: false,
83
+ synthesized: true,
84
+ base: base.name,
85
+ capabilities: "Cookbook setup and troubleshooting on this machine: connecting agents, MCP, the Bridge, and reading this setup under a hands grant.",
86
+ ...(base.token ? { token: base.token } : {}),
87
+ ...(base.cookbookUrl ? { cookbookUrl: base.cookbookUrl } : {}),
88
+ ...(base.approvalRelay ? { approvalRelay: base.approvalRelay } : {}),
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Is this pulled work item a support conversation addressed to Chef?
94
+ *
95
+ * The server sets assigned_to "Chef" only on support-workspace tasks, so the name is
96
+ * trustworthy on its own. When the item also carries workspace_kind (listOpenWork
97
+ * may shape it in), it must say "support"; any other kind is someone's ordinary
98
+ * task that happens to mention Chef, and the Bridge leaves it alone.
99
+ */
100
+ export function wantsLocalChef(task) {
101
+ if (!task) return false;
102
+ const to = String(task.assigned_to ?? "").trim().toLowerCase();
103
+ if (to !== CHEF_NAME.toLowerCase()) return false;
104
+ const kind = task.workspace_kind ?? task.workspace?.kind;
105
+ if (kind === undefined || kind === null) return true;
106
+ return String(kind).toLowerCase() === "support";
107
+ }
108
+
109
+ // One synthesized agent per (persona path, base command): bridge.mjs keys plan
110
+ // holds and warm pools by agent.name, and a fresh object every poll would still be
111
+ // fine, but a stable one keeps the logs and any identity-keyed maps honest.
112
+ const synthesized = new Map();
113
+
114
+ /**
115
+ * The agent that runs this task, for bridge.mjs dispatch: a configured agent that
116
+ * matches the assignee wins; otherwise, when the task wants Chef and the persona is
117
+ * shipped next to the Bridge, the synthesized local Chef; otherwise null (nothing
118
+ * on this Bridge handles that assignee, exactly as before).
119
+ *
120
+ * `opts.personaPath` and `opts.exists` exist for tests; production callers pass
121
+ * neither.
122
+ */
123
+ export function resolveAgentForTask(agents, task, cfg, opts = {}) {
124
+ const pool = Array.isArray(agents) ? agents : (cfg?.agents ?? []);
125
+ const configured = pool.find((a) => matchesAssignee(a, task?.assigned_to)) ?? null;
126
+ if (configured) return configured;
127
+ if (!wantsLocalChef(task)) return null;
128
+ const personaPath = opts.personaPath ?? personaPathNextTo();
129
+ const exists = opts.exists ?? existsSync;
130
+ if (!exists(personaPath)) return null;
131
+ const base = pool.find(isClaudeAgent);
132
+ if (!base) return null;
133
+ const key = `${personaPath}::${JSON.stringify(base.command)}::${base.token ?? ""}`;
134
+ let agent = synthesized.get(key);
135
+ if (!agent) {
136
+ agent = chefLocalAgent(cfg, pool, personaPath);
137
+ if (agent) synthesized.set(key, agent);
138
+ }
139
+ return agent ?? null;
140
+ }
@@ -12,7 +12,7 @@
12
12
  "allowApiKeyBilling": false,
13
13
  "_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities — decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
14
14
  "volunteering": true,
15
- "_autoUpdate": "The Bridge follows the app: it checks the deploy's file manifest at startup + every 6h, and self-updates (hash-verified, originals kept in bridge.backup/, your config/token never touched) then restarts itself. Set false to pin your version and update manually with `node bridge.mjs update`.",
15
+ "_autoUpdate": "The Bridge follows the app: it checks the deploy's file manifest at startup + every 6h, and self-updates (hash-verified, originals kept in bridge.backup/, your config/token never touched) then restarts itself. Set false to pin your version and update manually (npm: re-run `npx cookbook-bridge@latest`; tarball: `node bridge/bridge.mjs update`).",
16
16
  "autoUpdate": true,
17
17
  "default": "Gemini",
18
18
  "_acceptFrom": "Who may auto-run tasks on this Bridge: \"anyone\", or a list of EXACT member names or profile ids (case-insensitive, no partial matching — consent never guesses).",
@@ -34,7 +34,7 @@
34
34
  "--print-timeout",
35
35
  "3600s"
36
36
  ],
37
- "_setup": "Gemini runs via the Antigravity CLI (agy) — the old `gemini` CLI stopped serving individual accounts June 18 2026. Requires agy >= 1.1.1 (first version whose headless -p can call MCP tools; the Bridge refuses older). Auth: run `agy` once interactively and sign in with Google. MCP: `node bridge/bridge.mjs connect-agents` writes ~/.gemini/config/mcp_config.json (agy has no `mcp add`). --sandbox keeps terminal restrictions on — Cookbook work flows through MCP tools, not the shell. Keep --print-timeout >= taskTimeoutSeconds (ceiling) so agy doesn't cut off before the Bridge's own timeout.",
37
+ "_setup": "Gemini runs via the Antigravity CLI (agy); the old `gemini` CLI stopped serving individual accounts June 18 2026. Requires agy >= 1.1.1 (first version whose headless -p can call MCP tools; the Bridge refuses older). Auth: run `agy` once interactively and sign in with Google. MCP: `npx cookbook-bridge@latest connect` (tarball: `node bridge/bridge.mjs connect`) writes ~/.gemini/config/mcp_config.json (agy has no `mcp add`). --sandbox keeps terminal restrictions on; Cookbook work flows through MCP tools, not the shell. Keep --print-timeout >= taskTimeoutSeconds (ceiling) so agy doesn't cut off before the Bridge's own timeout.",
38
38
  "_usage": "agy emits no token/usage JSON — the board shows wall-clock duration for its runs (no token count)."
39
39
  },
40
40
  {
@@ -54,7 +54,7 @@
54
54
  "--output-format",
55
55
  "json"
56
56
  ],
57
- "_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause — `node bridge.mjs doctor` checks it.",
57
+ "_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause; `npx cookbook-bridge@latest doctor` (tarball: `node bridge/bridge.mjs doctor`) checks it.",
58
58
  "_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board — text works too, you just lose the usage report"
59
59
  },
60
60
  {
package/connectors.mjs CHANGED
@@ -20,6 +20,7 @@
20
20
  import fs from "node:fs";
21
21
  import os from "node:os";
22
22
  import path from "node:path";
23
+ import { cli } from "./update.mjs";
23
24
 
24
25
  const HOME = os.homedir();
25
26
  export const VENDORS = {
@@ -337,7 +338,7 @@ export function applyTeamSync(teamRows, vendors, { cfgPath = null } = {}) {
337
338
  }
338
339
  }
339
340
  for (const row of waiting) {
340
- lines.push(` ? connector "${row.name}" runs a command on this machine (${[row.command, ...(row.args ?? [])].join(" ").slice(0, 80)}). Not installed until you approve it: cookbook-bridge connectors approve ${row.name}`);
341
+ lines.push(` ? connector "${row.name}" runs a command on this machine (${[row.command, ...(row.args ?? [])].join(" ").slice(0, 80)}). Not installed until you approve it: ${cli(`connectors approve ${row.name}`)}`);
341
342
  }
342
343
  for (const row of teamRows) {
343
344
  const needs = Array.isArray(row.secret_env) ? row.secret_env.filter((k) => !process.env[k]) : [];
package/device.mjs CHANGED
@@ -31,6 +31,7 @@ import { spawn, spawnSync } from "node:child_process";
31
31
  import { fileURLToPath } from "node:url";
32
32
  import { listWorkspaces } from "./cookbook.mjs";
33
33
  import { which, argvForSpawn } from "./hands.mjs";
34
+ import { locateConfig, writeConfigFile, cli, updateLine, checkForUpdate } from "./update.mjs";
34
35
 
35
36
  const HERE = path.dirname(fileURLToPath(import.meta.url));
36
37
  const DEFAULT_URL = "https://cookbook.team";
@@ -42,11 +43,11 @@ function flag(argv, name) {
42
43
  return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
43
44
  }
44
45
 
45
- /** Path to config.json: --config <path> or the default bridge/config.json. */
46
+ /** Path to the config: --config <path>, else COOKBOOK_CONFIG, else ~/.cookbook/config.json
47
+ * (a legacy config next to bridge.mjs is copied there once). Subcommands take no
48
+ * positional path: `connect --url https://x` must not read the URL as a file. */
46
49
  export function configPath(argv) {
47
- return flag(argv, "--config")
48
- ? path.resolve(flag(argv, "--config"))
49
- : path.join(HERE, "config.json");
50
+ return locateConfig(argv || [], { here: HERE, positional: false, log: (m) => console.error(m) });
50
51
  }
51
52
 
52
53
  /** Read existing config (or null if none). */
@@ -205,9 +206,8 @@ export function saveLoginConfig(cfgPath, baseUrl, token, { found = null } = {})
205
206
  }
206
207
  cfg.cookbookUrl = baseUrl;
207
208
  cfg.token = token;
208
- fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
209
- fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
210
- try { fs.chmodSync(cfgPath, 0o600); } catch { /* best-effort on platforms without chmod */ }
209
+ // Home dir 0700, file 0600: this file carries every token the Bridge holds.
210
+ writeConfigFile(cfgPath, JSON.stringify(cfg, null, 2) + "\n");
211
211
  return cfg;
212
212
  }
213
213
 
@@ -477,7 +477,7 @@ export async function login(argv, opts = {}) {
477
477
  } catch (e) {
478
478
  console.log(`\n ⚠ Connected and saved config, but a test call failed: ${e.message}`);
479
479
  }
480
- if (!opts.quietOutro) console.log(" Start the Bridge with: node bridge/bridge.mjs\n");
480
+ if (!opts.quietOutro) console.log(` Start the Bridge with: ${cli()}\n`);
481
481
  return loginResult;
482
482
  }
483
483
 
@@ -485,7 +485,7 @@ export async function status(argv) {
485
485
  const cfgPath = configPath(argv);
486
486
  const cfg = readConfig(cfgPath);
487
487
  if (!cfg || !cfg.token || !cfg.cookbookUrl) {
488
- console.log(`No connected Bridge at ${cfgPath}.\nRun: node bridge/bridge.mjs login`);
488
+ console.log(`No connected Bridge at ${cfgPath}.\nRun: ${cli("connect")}`);
489
489
  return;
490
490
  }
491
491
  const cookbookUrl = String(cfg.cookbookUrl).replace(/\/$/, "");
@@ -545,28 +545,40 @@ function probeAgent(cmd) {
545
545
  * Codex (ChatGPT app) and OpenClaw; mints one named token per agent in the same
546
546
  * approval as the Bridge token; configures each via its official path.
547
547
  */
548
- export async function connectAgents(argv) {
548
+ export async function connectAgents(argv, { willRun = false } = {}) {
549
+ const cfgPath = configPath(argv);
549
550
  const found = detectClis();
550
551
  if (found.length === 0) {
551
552
  console.log("\nNo agent CLIs found (looked for: claude, agy, codex/ChatGPT.app, openclaw).");
552
- console.log("Install one, then re-run: node bridge/bridge.mjs connect-agents\n");
553
- return;
553
+ console.log(`Install one, then re-run: ${cli("connect")}\n`);
554
+ return { ok: false, reason: "no-agents", cfgPath };
554
555
  }
555
556
  console.log(`\nFound agent CLIs: ${found.map((c) => c.agent).join(", ")}`);
556
557
 
557
558
  // One approval mints the bridge token + one named token per agent.
558
559
  const res = await login(argv, { agents: found.map((c) => c.agent), quietOutro: true });
559
560
  if (!res || !res.agentTokens) {
560
- console.log("\n⚠ Approved, but no agent tokens were returned — your Bridge login was still refreshed.");
561
- console.log(" Re-run `connect-agents`, or create tokens manually at Account → Tokens.\n");
562
- return;
561
+ console.log("\n⚠ Approved, but no agent tokens were returned. Your Bridge login was still refreshed.");
562
+ console.log(` Re-run \`${cli("connect")}\`, or create tokens manually at Account > Tokens.\n`);
563
+ return { ok: true, startBridge: true, cfgPath: res?.cfgPath || cfgPath, agentTokens: false };
563
564
  }
564
565
  const results = configureClis(found, { baseUrl: res.baseUrl, agentTokens: res.agentTokens, cfgPath: res.cfgPath });
565
566
  for (const r of results) {
566
567
  const mark = r.ok ? (r.warn ? "!" : "✓") : "✗";
567
- console.log(` ${mark} ${r.agent}: ${r.detail}${r.ok && !r.warn ? ` — work will be attributed "${r.agent} · via you".` : ""}`);
568
+ console.log(` ${mark} ${r.agent}: ${r.detail}${r.ok && !r.warn ? ` (work will be attributed "${r.agent} · via you")` : ""}`);
568
569
  }
569
- reportNextStep(res.cfgPath);
570
+ await reportStaleness(res.baseUrl);
571
+ reportNextStep(res.cfgPath, { willRun });
572
+ return { ok: true, startBridge: true, cfgPath: res.cfgPath, agentTokens: true };
573
+ }
574
+
575
+ /** The same manifest comparison the running Bridge does at startup, printed with the
576
+ * same fix line, so `connect` never leaves someone on a stale copy without a word. */
577
+ async function reportStaleness(baseUrl) {
578
+ try {
579
+ const check = await checkForUpdate({ cookbookUrl: baseUrl }, HERE);
580
+ if (check.changed.length) console.log(`\n ⬆ ${updateLine(check.version, { here: HERE })}`);
581
+ } catch { /* offline or no manifest: the Bridge re-checks every 6h */ }
570
582
  }
571
583
 
572
584
  /**
@@ -576,35 +588,96 @@ export async function connectAgents(argv) {
576
588
  * Bridge was using (one active Bridge token per member), so a Bridge started
577
589
  * earlier is now silently 401-looping. Say both things, precisely.
578
590
  */
579
- function reportNextStep(cfgPath) {
580
- // How was this invoked? Tell them the command that matches their install.
581
- const viaPackage = HERE.includes(`${path.sep}node_modules${path.sep}`) || HERE.includes(`${path.sep}_npx${path.sep}`);
582
- const runCmd = viaPackage ? "npx cookbook-bridge" : "node bridge/bridge.mjs";
583
- const doctorCmd = `${runCmd} doctor`;
591
+ function reportNextStep(cfgPath, { willRun = false } = {}) {
592
+ // How was this invoked? cli() phrases the command for this install's layout.
593
+ const runCmd = cli();
594
+ const doctorCmd = cli("doctor");
584
595
 
585
596
  const running = findRunningBridges(cfgPath);
586
597
  console.log("");
587
598
  if (running.length === 0) {
588
- console.log(" Nothing is running yet. Connecting does not start the Bridge — start it now:");
589
- console.log(`\n ${runCmd}\n`);
590
- console.log(" Leave it running and your agents answer in Cookbook. The desktop app keeps");
591
- console.log(" one running for you if you would rather not hold a terminal open.");
599
+ if (!willRun) {
600
+ console.log(" Nothing is running yet. Connecting with --no-run does not start the Bridge; start it now:");
601
+ console.log(`\n ${runCmd}\n`);
602
+ console.log(" Leave it running and your agents answer in Cookbook. The desktop app keeps");
603
+ console.log(" one running for you if you would rather not hold a terminal open.");
604
+ }
592
605
  } else {
593
606
  for (const r of running) {
594
607
  if (r.sameConfig) {
595
- console.log(` A Bridge is already running here (pid ${r.pid}). Restart it to pick up the new token:`);
596
- console.log(`\n kill ${r.pid} && ${runCmd}\n`);
608
+ console.log(` A Bridge is already running here (pid ${r.pid}). It must restart to pick up the new token:`);
609
+ console.log(willRun ? `\n kill ${r.pid} (this window takes over)\n` : `\n kill ${r.pid} && ${runCmd}\n`);
597
610
  } else {
598
611
  console.log(` ⚠ A Bridge is running (pid ${r.pid}) on a DIFFERENT config:`);
599
612
  console.log(` ${r.configPath}`);
600
613
  console.log(" This login replaced its token, so it can no longer claim work. Either");
601
614
  console.log(" reconnect from that install, or stop it and run the one you just set up:");
602
- console.log(`\n kill ${r.pid} && ${runCmd}\n`);
615
+ console.log(`\n kill ${r.pid}${willRun ? "" : ` && ${runCmd}`}\n`);
603
616
  }
604
617
  }
605
618
  }
619
+ console.log(` Config: ${cfgPath}`);
606
620
  console.log(` Check everything: ${doctorCmd}`);
607
- console.log(` Chat from here: ${runCmd} chat\n`);
621
+ console.log(` Chat from here: ${cli("chat")}\n`);
622
+ }
623
+
624
+ /**
625
+ * Every Bridge process on this machine, from the process table: `ps` on POSIX, and
626
+ * on Windows PowerShell's Win32_Process (command lines) with `tasklist` as the
627
+ * fallback (pids only). Returns [] when none, null when the scan itself failed.
628
+ * The parser is pure and exported for the test.
629
+ */
630
+ export function parseBridgeProcesses(text, { platform = process.platform, selfPid = process.pid, format = platform === "win32" ? "tasklist" : "ps" } = {}) {
631
+ const out = [];
632
+ for (const raw of String(text || "").split(/\r?\n/)) {
633
+ const line = raw.trim();
634
+ if (!line) continue;
635
+ if (format === "tasklist") {
636
+ // "node.exe","1234","Console","1","12,345 K"
637
+ const m = line.match(/^"([^"]+)","(\d+)"/);
638
+ if (!m || !/^node(\.exe)?$/i.test(m[1])) continue;
639
+ const pid = Number(m[2]);
640
+ if (pid === selfPid) continue;
641
+ out.push({ pid, configPath: null, unknownCommand: true });
642
+ continue;
643
+ }
644
+ // ps / CIM: "<pid> <command line>"
645
+ const m = line.match(/^(\d+)\s+(.*)$/);
646
+ if (!m) continue;
647
+ const pid = Number(m[1]);
648
+ const cmd = m[2];
649
+ if (pid === selfPid || !/bridge\.mjs(\s|"|$)/.test(cmd)) continue;
650
+ // Only a node process RUNNING bridge.mjs counts: a shell or editor whose command
651
+ // line merely mentions the file (a heredoc, `grep bridge.mjs`) is not a Bridge.
652
+ const first = (cmd.match(/^"([^"]*)"|^(\S+)/) || []).slice(1).find((x) => x !== undefined) ?? "";
653
+ if (!/(^|[\\/])node(\.exe)?$/i.test(first)) continue;
654
+ if (/\bnode\s+--check\b|--test\b/.test(cmd)) continue;
655
+ // Everything after bridge.mjs, kept whole: the desktop's config path has a space
656
+ // in it ("Application Support"), so a whitespace split would lose it.
657
+ const rest = (cmd.match(/bridge\.mjs"?\s*(.*)$/) || [, ""])[1].trim();
658
+ let configPath = null;
659
+ const cm = rest.match(/--config\s+"?(.+?)"?\s*$/);
660
+ if (cm) configPath = cm[1];
661
+ else if (/\.json"?$/i.test(rest)) configPath = rest.replace(/^"|"$/g, "");
662
+ out.push({ pid, configPath, unknownCommand: false });
663
+ }
664
+ return out;
665
+ }
666
+
667
+ export function scanBridgeProcesses({ selfPid = process.pid } = {}) {
668
+ const run = (cmd, args) => {
669
+ const r = spawnSync(cmd, args, { encoding: "utf8", timeout: 5000, windowsHide: true });
670
+ if (r.error || r.status !== 0) return null;
671
+ return r.stdout || "";
672
+ };
673
+ if (process.platform === "win32") {
674
+ const cim = run("powershell", ["-NoProfile", "-Command", "Get-CimInstance Win32_Process -Filter \"Name='node.exe'\" | ForEach-Object { \"$($_.ProcessId) $($_.CommandLine)\" }"]);
675
+ if (cim !== null) return parseBridgeProcesses(cim, { selfPid, format: "ps" });
676
+ const tl = run("tasklist", ["/FI", "IMAGENAME eq node.exe", "/FO", "CSV", "/NH"]);
677
+ return tl === null ? null : parseBridgeProcesses(tl, { selfPid, format: "tasklist" });
678
+ }
679
+ const ps = run("ps", ["-axo", "pid=,command="]) ?? run("ps", ["-eo", "pid=,args="]);
680
+ return ps === null ? null : parseBridgeProcesses(ps, { selfPid, format: "ps" });
608
681
  }
609
682
 
610
683
  /**
@@ -615,7 +688,8 @@ function reportNextStep(cfgPath) {
615
688
  * confusion.
616
689
  */
617
690
  function findRunningBridges(cfgPath) {
618
- const candidates = [cfgPath];
691
+ // The config we just wrote, a pre-0.1.11 install's folder, and the desktop app's data dir.
692
+ const candidates = [cfgPath, path.join(HERE, "config.json")];
619
693
  if (process.platform === "darwin") {
620
694
  candidates.push(path.join(os.homedir(), "Library", "Application Support", "ai.cookbook.desktop", "config.json"));
621
695
  }
@@ -695,8 +769,8 @@ export async function host(argv) {
695
769
  }
696
770
  writeHosting(cfgPath, true);
697
771
  console.log("\n ✓ This machine can now host a visiting agent, inside a grant you approve.");
698
- console.log(" Leave this running and invite one from Cookbook — you'll see every step as it happens.\n");
699
- return { ok: true, startBridge: true };
772
+ console.log(" Leave this running and invite one from Cookbook; you'll see every step as it happens.\n");
773
+ return { ok: true, startBridge: true, cfgPath };
700
774
  }
701
775
 
702
776
  /** Is that pid still alive? (signal 0 = existence check, no signal delivered) */
@@ -709,5 +783,5 @@ function pidAlive(pid) {
709
783
  function writeHosting(cfgPath, enabled) {
710
784
  const raw = readConfig(cfgPath) ?? {};
711
785
  raw.hosting = { ...(raw.hosting ?? {}), enabled };
712
- fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2) + "\n", { mode: 0o600 });
786
+ writeConfigFile(cfgPath, JSON.stringify(raw, null, 2) + "\n");
713
787
  }
package/hands.mjs CHANGED
@@ -140,6 +140,8 @@ export const SETUP_FILES = Object.freeze([
140
140
  ".gemini/antigravity-cli/settings.json",
141
141
  ".openclaw/openclaw.json",
142
142
  ".cookbook/config.json", // Bridge config (projected: tokens stripped)
143
+ ".cookbook/bridge.state.json", // attempt counters (projected)
144
+ ".cookbook/local.json", // also on NEVER_READ (the loopback token); deny wins
143
145
  // The desktop app's own files (macOS). All projected: a Bridge config carries the
144
146
  // Bridge token and per-agent tokens, and a visitor only needs its SHAPE.
145
147
  "Library/Application Support/ai.cookbook.desktop/config.json",
@@ -188,6 +190,7 @@ const NEVER_READ = Object.freeze([
188
190
  /\.(pem|key|p12|pfx|keystore|jks|asc)$/i,
189
191
  /(^|\/)id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/i,
190
192
  /(^|\/)local\.json$/i, // the Bridge Local loopback token
193
+ /(^|\/)\.cookbook\/local\.json$/i, // the config home's copy, named explicitly like the Library one
191
194
  /(^|\/)\.git\/config$/i, // can carry credentials in a remote URL
192
195
  // `credentials.json` AND its dotfile spelling `.credentials.json` — the anchor
193
196
  // used to accept only `/` before the word, so a dotfile in a granted project
@@ -274,6 +277,8 @@ export function projectedConfig(text) {
274
277
 
275
278
  const PROJECTIONS = Object.freeze({
276
279
  ".cookbook/config.json": projectedConfig,
280
+ ".cookbook/bridge.state.json": projectedConfig,
281
+ ".cookbook/local.json": projectedConfig,
277
282
  "Library/Application Support/ai.cookbook.desktop/config.json": projectedConfig,
278
283
  "Library/CookbookBridge/config.json": projectedConfig,
279
284
  "Library/CookbookBridge/local.json": projectedConfig,
@@ -658,7 +663,7 @@ export const RUN_TEMPLATES = Object.freeze({
658
663
  const bin = which("claude");
659
664
  if (!bin) return { error: "The claude CLI isn't installed on this machine (not on PATH)." };
660
665
  const raw = ctx?.cfg?.cookbookUrl;
661
- if (!raw) return { error: "This Bridge has no cookbookUrl in its config, so there is nothing to point Claude at. Run `cookbook-bridge connect` first." };
666
+ if (!raw) return { error: "This Bridge has no cookbookUrl in its config, so there is nothing to point Claude at. Run `npx cookbook-bridge@latest connect` first." };
662
667
  let origin;
663
668
  try { origin = new URL(String(raw)).origin; } catch { return { error: `cookbookUrl in this Bridge's config is not a valid URL (${String(raw).slice(0, 80)}).` }; }
664
669
  return { argv: [bin, "mcp", "add", "--transport", "http", "--scope", "user", "cookbook", `${origin}/api/mcp`], timeoutMs: 45_000 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cookbook-bridge",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "Run your own Claude, Codex and Gemini subscriptions against your Cookbook workspaces. One approval connects every agent CLI on your machine, with a receipt for every run.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  "approve-mcp.mjs",
13
13
  "bridge.mjs",
14
14
  "chat.mjs",
15
+ "chef-persona.md",
16
+ "chef.mjs",
15
17
  "codex-runner.mjs",
16
18
  "config.example.json",
17
19
  "connectors.mjs",
@@ -38,7 +40,7 @@
38
40
  "node": ">=18"
39
41
  },
40
42
  "scripts": {
41
- "test": "node --test test/local.test.mjs test/review.test.mjs"
43
+ "test": "node --test test/local.test.mjs test/review.test.mjs test/home.test.mjs"
42
44
  },
43
45
  "keywords": [
44
46
  "cookbook",
package/update.mjs CHANGED
@@ -17,10 +17,124 @@
17
17
  * scripts/test-bridge-update.ts; I/O lives in checkForUpdate/applyUpdate.
18
18
  */
19
19
  import fs from "node:fs";
20
+ import os from "node:os";
20
21
  import path from "node:path";
22
+ import { fileURLToPath } from "node:url";
21
23
  import { createHash } from "node:crypto";
22
24
  import { gunzipSync } from "node:zlib";
23
25
 
26
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
27
+
28
+ // ── The Bridge's home + how to phrase a command ─────────────────────────────
29
+ // These live HERE (node built-ins only, imported by every command including the
30
+ // broken-install `update` path) so that connecting, running and updating all
31
+ // agree on where the config is and on what command to print. Before 0.1.11 the
32
+ // config sat next to bridge.mjs, which meant an npx cache: every `@latest` moved
33
+ // the Bridge to a new folder and lost its config, and every hint said
34
+ // `node bridge/bridge.mjs` to people who had never unpacked a tarball.
35
+
36
+ /** `~/.cookbook`: config.json, bridge.state.json, local.json and bridge.log live here. */
37
+ export function configHome(home = os.homedir()) {
38
+ return path.join(home, ".cookbook");
39
+ }
40
+
41
+ export function defaultConfigPath(home = os.homedir()) {
42
+ return path.join(configHome(home), "config.json");
43
+ }
44
+
45
+ /**
46
+ * Where the config is, as data (no writes). Order:
47
+ * 1. `--config <path>`, or (when `positional` is on) the first bare argument
48
+ * 2. COOKBOOK_CONFIG
49
+ * 3. ~/.cookbook/config.json when it exists
50
+ * 4. legacy <here>/config.json when it exists and 3 does not: the answer is still
51
+ * the home path, with `migrateFrom` set so locateConfig can copy it over
52
+ * 5. ~/.cookbook/config.json (the place a first `connect` will write)
53
+ * Pure so the order is testable: `exists`, `env`, `home` and `here` are injectable.
54
+ */
55
+ export function resolveConfigPath(args = [], { env = process.env, home = os.homedir(), here = HERE, exists = fs.existsSync, positional = true } = {}) {
56
+ const list = Array.isArray(args) ? args : [];
57
+ const i = list.indexOf("--config");
58
+ if (i >= 0 && list[i + 1]) return { path: path.resolve(list[i + 1]), source: "flag" };
59
+ if (positional) {
60
+ const bare = list.find((a) => a && !String(a).startsWith("-"));
61
+ if (bare) return { path: path.resolve(bare), source: "arg" };
62
+ }
63
+ if (env && env.COOKBOOK_CONFIG) return { path: path.resolve(env.COOKBOOK_CONFIG), source: "env" };
64
+ const homePath = defaultConfigPath(home);
65
+ if (exists(homePath)) return { path: homePath, source: "home" };
66
+ const legacy = here ? path.join(here, "config.json") : null;
67
+ if (legacy && exists(legacy)) return { path: homePath, source: "legacy", migrateFrom: legacy };
68
+ return { path: homePath, source: "home" };
69
+ }
70
+
71
+ /** Create the home dir owner-only (0700). Best effort on platforms without modes. */
72
+ export function ensureConfigHome(dir) {
73
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
74
+ try { fs.chmodSync(dir, 0o700); } catch { /* win32 */ }
75
+ return dir;
76
+ }
77
+
78
+ /** Owner-only write of a config file (0600), creating its dir (0700) first. */
79
+ export function writeConfigFile(p, text) {
80
+ ensureConfigHome(path.dirname(p));
81
+ fs.writeFileSync(p, text, { mode: 0o600 });
82
+ try { fs.chmodSync(p, 0o600); } catch { /* win32 */ }
83
+ }
84
+
85
+ /**
86
+ * resolveConfigPath + the one side effect: a legacy <here>/config.json is COPIED to
87
+ * the home (the old file stays where it was) and one line says where the config now
88
+ * lives. Returns the path every command should use. Also carries bridge.state.json
89
+ * over when it sits next to the legacy config, so attempt counters survive the move.
90
+ */
91
+ export function locateConfig(args = [], opts = {}) {
92
+ const { log = (m) => console.error(m) } = opts;
93
+ const r = resolveConfigPath(args, opts);
94
+ if (r.source === "legacy" && r.migrateFrom) {
95
+ try {
96
+ writeConfigFile(r.path, fs.readFileSync(r.migrateFrom));
97
+ const oldState = path.join(path.dirname(r.migrateFrom), "bridge.state.json");
98
+ const newState = path.join(path.dirname(r.path), "bridge.state.json");
99
+ if (fs.existsSync(oldState) && !fs.existsSync(newState)) {
100
+ try { fs.copyFileSync(oldState, newState); } catch { /* counters are advisory */ }
101
+ }
102
+ log(`Config now lives at ${r.path} (copied from ${r.migrateFrom}; the old file was left in place).`);
103
+ } catch (e) {
104
+ // Can't create the home: keep working from the legacy file rather than fail.
105
+ log(`Could not move the config to ${r.path} (${e.message}); still using ${r.migrateFrom}.`);
106
+ return r.migrateFrom;
107
+ }
108
+ }
109
+ return r.path;
110
+ }
111
+
112
+ /**
113
+ * How was this copy installed? Decides how every hint spells a command.
114
+ * "npm" an npx cache (`_npx`) or an npm install (`node_modules/cookbook-bridge`)
115
+ * "tarball" bridge.mjs unpacked from the download (or a source checkout)
116
+ */
117
+ export function installLayout(here = HERE) {
118
+ const n = String(here).replace(/\\/g, "/") + "/";
119
+ if (n.includes("/_npx/") || n.includes("/node_modules/cookbook-bridge/")) return "npm";
120
+ return "tarball";
121
+ }
122
+
123
+ /** The ONE way to print a Bridge command: `cli("doctor")` reads `npx cookbook-bridge@latest doctor`
124
+ * on an npm install and `node bridge/bridge.mjs doctor` from a tarball. Bare `cli()` is the run command. */
125
+ export function cli(cmd = "", { here = HERE } = {}) {
126
+ const base = installLayout(here) === "npm" ? "npx cookbook-bridge@latest" : "node bridge/bridge.mjs";
127
+ return cmd ? `${base} ${cmd}` : base;
128
+ }
129
+
130
+ /** The exact line printed when the local files are behind the deploy. Shared by the
131
+ * running Bridge's startup check, `doctor` and `connect`, so they never disagree. */
132
+ export function updateLine(version, { here = HERE, desktop = process.env.COOKBOOK_DESKTOP === "1" } = {}) {
133
+ if (desktop) return `A newer Bridge ships with the app (deploy ${version}). The Cookbook app manages this copy; update the app to pick it up.`;
134
+ if (installLayout(here) === "npm") return `A newer Bridge is available (deploy ${version}). Update it with: ${cli("", { here })} (@latest fetches the new copy; your config stays in ~/.cookbook)`;
135
+ return `A newer Bridge is available (deploy ${version}). Update it with: ${cli("update", { here })}`;
136
+ }
137
+
24
138
  /** Files the updater manages — must mirror the server's BRIDGE_RUNTIME_FILES. The list
25
139
  * itself comes from the MANIFEST at update time (server-driven), so a future deploy can
26
140
  * add files without this constant; this is only the local-hash candidate set. */