mobygate 0.7.2 → 0.8.0

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/CHANGELOG.md CHANGED
@@ -4,6 +4,151 @@ All notable changes to mobygate are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version numbers are
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [0.8.0] — 2026-04-25
8
+
9
+ Auto-wire third-party clients to use mobygate. The "find your client's
10
+ config file → paste this JSON → restart" dance is replaced by
11
+ `mobygate connect`.
12
+
13
+ ### Added
14
+
15
+ - **`mobygate connect` CLI** — auto-detects supported clients on the
16
+ system, plans the minimal config edit to register mobygate as a
17
+ provider, and applies it atomically with a backup. Subcommands:
18
+ - `mobygate connect` — interactive, lists detected clients and asks
19
+ which to wire (default: all)
20
+ - `mobygate connect <id>` — wire one specific client
21
+ - `mobygate connect --all` — every detected client, no prompts
22
+ - `mobygate connect --dry-run` — show planned diff, don't write
23
+ - `mobygate connect --yes` — skip the per-client confirm prompt
24
+ - `mobygate connect --no-default` — register the provider but don't
25
+ change the client's active default model
26
+ - `mobygate disconnect <id>` — remove our entries cleanly
27
+ - **Auto-wire prompt at the end of `mobygate init`** — after services
28
+ start, if any supported client is detected, init asks "wire them
29
+ up?" before printing Done. Skipped in `--yes` mode (we don't edit
30
+ third-party configs in non-interactive flows without explicit opt-in).
31
+ - **Hermes connector** (`lib/connectors/hermes.js`) — registers the
32
+ `moby` provider in `~/.hermes/config.yaml`. Hermes only speaks the
33
+ OpenAI-compat wire format, so a single provider entry covers it.
34
+ - **OpenClaw connector** (`lib/connectors/openclaw.js`) — registers
35
+ BOTH `moby` (OpenAI-compat) and `moby-native` (Anthropic-messages)
36
+ in `~/.openclaw/openclaw.json`. With `setDefault`, points OpenClaw's
37
+ main + default at `moby-native/claude-opus-4-7` — the wire format
38
+ that unlocks vision, native tool calls, and reasoning blocks.
39
+
40
+ ### Provider naming
41
+
42
+ All registered entries use the `moby` prefix to be unmistakably from
43
+ mobygate:
44
+ - **`moby`** — OpenAI-compat surface (POST /v1/chat/completions)
45
+ - **`moby-native`** — Anthropic-messages surface (POST /v1/messages)
46
+
47
+ ### Safety
48
+
49
+ Every config write goes through `lib/connectors/safety.js`:
50
+ - Timestamped backup at `<file>.mobygate-backup-<ISO>` before any write
51
+ - Atomic write via temp file + rename
52
+ - Read-back verification that the on-disk content matches what we
53
+ intended to write
54
+ - Idempotent — running `connect` twice doesn't duplicate entries; we
55
+ detect existing moby entries by key and update vs append
56
+
57
+ ### Caveats
58
+
59
+ - **YAML comments are not preserved.** Hermes config uses YAML, and
60
+ `js-yaml` parse-then-emit drops comments and blank lines. The
61
+ auto-backup catches this — the connector also warns on first
62
+ detection. Comment-preserving YAML is a v0.8.x candidate.
63
+ - **Custom config locations not yet supported.** Connectors probe
64
+ `~/.hermes/` and `~/.openclaw/`. If you've installed a client to a
65
+ non-standard path, you'd still need to wire it manually, or set
66
+ `HERMES_HOME` / `OPENCLAW_HOME` env vars (which the connectors
67
+ honor).
68
+ - **No `verify()` step yet.** The connector contract has slots for it,
69
+ but v0.8.0 doesn't fire a real test request through the wired
70
+ client. Verification is on the v0.8.x roadmap.
71
+
72
+ ### v0.8.x backlog (deferred)
73
+
74
+ - More connectors (pi-agent, Goose, Aider, Cursor — anything that
75
+ takes a custom OpenAI-compat baseURL)
76
+ - `verify()` step that exercises the new provider end-to-end
77
+ - Comment-preserving YAML for Hermes
78
+ - Auxiliary-client routing helpers (pin Hermes vision/compression to
79
+ Sonnet/Haiku via mobygate to save Opus quota)
80
+ - `mobygate doctor` extended to check connection health per client
81
+
82
+ ## [0.7.3] — 2026-04-25
83
+
84
+ Hotfix bundle from a thorough security + bugs + ops audit. Six items.
85
+
86
+ ### Fixed (security)
87
+
88
+ - **Same-origin gate on control-plane endpoints.** `/update/apply`,
89
+ `/auth/refresh`, `DELETE /sessions(/:key)`, `/dashboard/logs`, and
90
+ `/events` now require the request's `Host` header to be localhost
91
+ and (when present) the `Origin` header to match. This blocks the
92
+ DNS-rebinding scenario where a malicious site reroutes its DNS to
93
+ `127.0.0.1` and triggers `npm install -g`, drains Claude Max quota
94
+ via auth-refresh spam, or tails prompt content from server logs
95
+ through any browser tab the user has open. Proxy endpoints
96
+ (`/v1/chat/completions`, `/v1/messages`, `/v1/models`, `/health`)
97
+ stay open for client traffic.
98
+
99
+ ### Fixed (operational)
100
+
101
+ - **Dashboard "Update now" silently no-op'd on Windows.** `lib/updater.js`
102
+ hardcoded `WIN_SERVER_TASK = 'ai.mobygate.server'` while the task
103
+ `lib/platform.js` actually registers is `'mobygate-server'`. The
104
+ cmd.exe `&&` chain short-circuited on the failed `schtasks /End`
105
+ and never ran `npm install`. Now imports `WIN_LABELS` and
106
+ `LINUX_UNITS` directly from platform.js, single-source-of-truth.
107
+ - **`mobygate stop` and `mobygate update` now stop BOTH services**
108
+ (server + auth-refresh) on all three platforms. Earlier the auth
109
+ task could fire mid-update on Windows, grab file handles in
110
+ `node_modules\mobygate`, and trigger EBUSY — root cause of the
111
+ v0.6/v0.7 EBUSY churn. The dashboard `/update/apply` flow also
112
+ stops both services in the detached child script.
113
+ - **Mac auth-refresh plist now generated programmatically.** Earlier
114
+ releases shipped a static plist template (`launchd/ai.mobygate.auth-refresh.plist`)
115
+ with hardcoded paths from Farhan's machine and sed-replaced them
116
+ at install time. Anyone whose username, install path, or fnm
117
+ Node version didn't EXACTLY match the patterns ended up with a
118
+ plist pointing at non-existent paths and the cron silently
119
+ failed forever. New `writeMacAuthRefreshPlist()` in `lib/platform.js`
120
+ mirrors `writeMacServerPlist`, generates from the user's actual
121
+ paths, portable across any user.
122
+
123
+ ### Fixed (bugs)
124
+
125
+ - **Image + 401 auth-retry no longer hangs / returns empty.** When a
126
+ multimodal request hit the SDK right as the OAuth token expired,
127
+ `runWithAuthRetry` would invoke `runQuery` a second time with the
128
+ same already-exhausted async iterator (multimodal returns a
129
+ single-use generator). The SDK got an empty user message and the
130
+ client received an empty response. `prompt` is now built lazily
131
+ inside `runQuery` so each retry attempt rebuilds the iterator.
132
+ All four handlers fixed.
133
+ - **400 instead of "model responds to its own reply"** when a resumed
134
+ request's history terminates with an assistant turn. Earlier
135
+ `messagesToPrompt` in resume mode fell back to extracting whatever
136
+ was at `messages[-1]`, sending the assistant's previous reply to
137
+ the SDK as the new user prompt. Now both `messagesToPrompt`
138
+ (OpenAI) and `anthropicMessagesToPrompt` (Anthropic) return a
139
+ structured `{ promptText, error }` and the handler returns 400
140
+ with a readable message when the trailing turn isn't user/tool.
141
+
142
+ ### Notes
143
+
144
+ - For LAN-exposed installs (`bind: 0.0.0.0`), the same-origin gate
145
+ is necessary but not sufficient — anyone on the LAN can still hit
146
+ endpoints with a faked `Host` header. A real `MOBYGATE_TOKEN` for
147
+ LAN auth is queued for a follow-up release.
148
+ - The `launchd/ai.mobygate.auth-refresh.plist` template file is now
149
+ unused. Left in the package for backward compatibility — won't
150
+ ship in a future release.
151
+
7
152
  ## [0.7.2] — 2026-04-25
8
153
 
9
154
  ### Fixed
package/bin/mobygate.js CHANGED
@@ -28,7 +28,7 @@ import { loadConfig, writeConfig, writeState, readState, CONFIG_DIR, CONFIG_PATH
28
28
  import {
29
29
  PLATFORM, IS_MAC, IS_LINUX, IS_WIN,
30
30
  resolveNodeBin,
31
- writeMacServerPlist, launchctlLoad, launchctlUnload,
31
+ writeMacServerPlist, writeMacAuthRefreshPlist, launchctlLoad, launchctlUnload,
32
32
  plistPathForLabel, queryLaunchd, uninstallAllServices,
33
33
  installWindowsServices, uninstallWindowsServices,
34
34
  queryWindowsTask, startWindowsTask, stopWindowsTask, WIN_LABELS,
@@ -39,6 +39,13 @@ import {
39
39
  } from '../lib/platform.js';
40
40
  import { getAuthStatus, forceRefresh } from '../scripts/auth-helper.js';
41
41
  import { banner, compactBanner } from '../lib/ascii.js';
42
+ import {
43
+ CONNECTORS,
44
+ detectAll as detectAllConnectors,
45
+ getConnector,
46
+ DEFAULT_BASE_URL as MOBY_BASE_URL,
47
+ DEFAULT_API_KEY as MOBY_API_KEY,
48
+ } from '../lib/connectors/index.js';
42
49
 
43
50
  const __filename = fileURLToPath(import.meta.url);
44
51
  const REPO_ROOT = resolve(dirname(__filename), '..');
@@ -204,21 +211,18 @@ async function cmdInit() {
204
211
  launchctlLoad(serverPlist);
205
212
  ok(`Installed ${SERVER_LABEL} (launchd)`);
206
213
 
207
- // Auth refresh plist (we ship a template in launchd/ — copy + rewrite
208
- // WorkingDirectory, node path, and log paths to match this install).
209
- const authSrc = join(REPO_ROOT, 'launchd', 'ai.mobygate.auth-refresh.plist');
210
- if (existsSync(authSrc)) {
211
- const authDst = plistPathForLabel(AUTH_LABEL);
212
- const tmpl = readFileSync(authSrc, 'utf8')
213
- // WorkingDirectory + any path that referenced the repo root
214
- .replace(/\/Users\/farhan\/openclaude\/claude-max-sdk-proxy\/logs/g, logsDir)
215
- .replace(/\/Users\/farhan\/openclaude\/claude-max-sdk-proxy/g, REPO_ROOT)
216
- // node binary baked into ProgramArguments
217
- .replace(/\/Users\/farhan\/\.local\/share\/fnm\/aliases\/default\/bin\/node/g, nodeBin);
218
- writeFileSync(authDst, tmpl);
219
- launchctlLoad(authDst);
220
- ok(`Installed ${AUTH_LABEL} (launchd, every ${existing.auth_refresh_interval_hours}h)`);
221
- }
214
+ // Auth refresh plist generated programmatically with the user's
215
+ // actual paths. Earlier we shipped a static template and sed-replaced
216
+ // hardcoded paths inside it, which silently broke for anyone whose
217
+ // username/install-path/fnm-version didn't EXACTLY match Farhan's.
218
+ const authPlist = writeMacAuthRefreshPlist({
219
+ installPath: REPO_ROOT,
220
+ nodeBin,
221
+ logsDir,
222
+ intervalHours: existing.auth_refresh_interval_hours,
223
+ });
224
+ launchctlLoad(authPlist);
225
+ ok(`Installed ${AUTH_LABEL} (launchd, every ${existing.auth_refresh_interval_hours}h)`);
222
226
  } else if (IS_WIN) {
223
227
  // Register Task Scheduler entries and kick the server task now.
224
228
  const r = installWindowsServices({
@@ -283,6 +287,54 @@ async function cmdInit() {
283
287
  info('Start the server manually (see instructions above) then run `mobygate status`.');
284
288
  }
285
289
 
290
+ // ---- Auto-wire detected clients (Hermes, OpenClaw, etc.) ----
291
+ // Skip in non-interactive mode (--yes) — those flows shouldn't surprise
292
+ // the user by editing third-party config files. They can still run
293
+ // `mobygate connect --all --yes` explicitly afterward.
294
+ if (!nonInteractive) {
295
+ try {
296
+ const detected = await detectAllConnectors();
297
+ const present = detected.filter((d) => d.detection !== null);
298
+ if (present.length > 0) {
299
+ section('Detected clients');
300
+ for (const { connector, detection } of present) {
301
+ print(` ${c.green('✓')} ${c.bold(connector.displayName)} ${c.dim('at ' + detection.configPath)}`);
302
+ }
303
+ print('');
304
+ const wire = await confirm('Auto-wire these to use mobygate?', true);
305
+ if (wire) {
306
+ for (const { connector } of present) {
307
+ try {
308
+ const plan = await connector.plan({
309
+ baseUrl: MOBY_BASE_URL,
310
+ apiKey: MOBY_API_KEY,
311
+ setDefault: true,
312
+ });
313
+ if (plan.skip) { warn(`${connector.displayName}: ${plan.reason}`); continue; }
314
+ for (const w of plan.warnings || []) warn(w);
315
+ const result = await connector.apply(plan);
316
+ if (result.applied) {
317
+ ok(`Wired ${connector.displayName} → ${result.configPath}`);
318
+ if (result.backupPath) print(c.dim(` backup: ${result.backupPath}`));
319
+ } else {
320
+ warn(`${connector.displayName}: ${result.reason}`);
321
+ }
322
+ } catch (e) {
323
+ warn(`${connector.displayName}: ${e.message}`);
324
+ }
325
+ }
326
+ print('');
327
+ info('Restart the wired clients so they pick up the new provider.');
328
+ } else {
329
+ print(c.dim('Skipped — run `mobygate connect` later to wire them on demand.'));
330
+ }
331
+ }
332
+ } catch (e) {
333
+ // Detection failures should never block init.
334
+ warn(`Connector detection failed: ${e.message}`);
335
+ }
336
+ }
337
+
286
338
  section('Done');
287
339
  print(`Dashboard: ${c.cyan(`http://localhost:${port}`)}`);
288
340
  print(`Configure: ${c.cyan(CONFIG_PATH)}`);
@@ -320,25 +372,47 @@ function cmdStart() {
320
372
  }
321
373
 
322
374
  function cmdStop() {
375
+ // Stop BOTH services: the server AND the auth-refresh task. Earlier
376
+ // releases only stopped the server, leaving the 4-hourly auth-refresh
377
+ // cron free to fire mid-update and grab file handles in node_modules
378
+ // — that was the root cause of the v0.6/v0.7 EBUSY churn on Windows.
379
+ // We tolerate "not running" failures on both since the user just
380
+ // wants the end state of "nothing mobygate is running."
323
381
  if (IS_MAC) {
324
- const p = plistPathForLabel(SERVER_LABEL);
325
- launchctlUnload(p);
382
+ const serverPlist = plistPathForLabel(SERVER_LABEL);
383
+ const authPlist = plistPathForLabel(AUTH_LABEL);
384
+ launchctlUnload(serverPlist);
385
+ launchctlUnload(authPlist);
326
386
  ok(`Unloaded ${SERVER_LABEL}`);
387
+ ok(`Unloaded ${AUTH_LABEL}`);
327
388
  } else if (IS_WIN) {
328
- const r = stopWindowsTask(WIN_LABELS.server);
329
- if (!r.ok) return die(`Failed to stop ${WIN_LABELS.server}: ${r.stderr || 'unknown'}`);
330
- ok(`Stopped ${WIN_LABELS.server}`);
389
+ const rServer = stopWindowsTask(WIN_LABELS.server);
390
+ const rAuth = stopWindowsTask(WIN_LABELS.auth);
391
+ if (rServer.ok) ok(`Stopped ${WIN_LABELS.server}`);
392
+ else warn(`${WIN_LABELS.server}: ${rServer.stderr || 'not running or already stopped'}`);
393
+ if (rAuth.ok) ok(`Stopped ${WIN_LABELS.auth}`);
394
+ else warn(`${WIN_LABELS.auth}: ${rAuth.stderr || 'not running or already stopped'}`);
331
395
  } else if (IS_LINUX) {
332
- const r = stopLinuxUnit(LINUX_UNITS.server);
333
- if (!r.ok) return die(`Failed to stop ${LINUX_UNITS.server}: ${r.stderr || 'unknown'}`);
334
- ok(`Stopped ${LINUX_UNITS.server}`);
396
+ const rServer = stopLinuxUnit(LINUX_UNITS.server);
397
+ if (rServer.ok) ok(`Stopped ${LINUX_UNITS.server}`);
398
+ else warn(`${LINUX_UNITS.server}: ${rServer.stderr || 'not running or already stopped'}`);
399
+ if (LINUX_UNITS.timer) {
400
+ const rTimer = stopLinuxUnit(LINUX_UNITS.timer);
401
+ if (rTimer.ok) ok(`Stopped ${LINUX_UNITS.timer}`);
402
+ }
403
+ if (LINUX_UNITS.auth) {
404
+ const rAuth = stopLinuxUnit(LINUX_UNITS.auth);
405
+ if (rAuth.ok) ok(`Stopped ${LINUX_UNITS.auth}`);
406
+ }
335
407
  } else {
336
408
  die('`mobygate stop` not supported on this platform.');
337
409
  }
338
410
  }
339
411
 
340
412
  function cmdRestart() {
341
- cmdStop();
413
+ // Tolerate cmdStop failure (target may already be stopped). Only die
414
+ // on cmdStart errors, which are the actually-blocking ones.
415
+ try { cmdStop(); } catch {}
342
416
  cmdStart();
343
417
  }
344
418
 
@@ -581,23 +655,23 @@ async function cmdUpdate() {
581
655
  }
582
656
  print('');
583
657
 
584
- // ---- Stop the service FIRST on Windows, otherwise running Node holds
585
- // open file handles inside the install dir and `npm install -g` fails
586
- // with EBUSY when it tries to rename the directory. On macOS/Linux we
587
- // can replace open files freely, but stopping early there too is harmless
588
- // and gives a cleaner restart sequence — so we do it everywhere.
589
- let stoppedForUpdate = false;
658
+ // ---- Stop BOTH services first (server + auth-refresh). The auth task
659
+ // imports mobygate code from the same node_modules dir, so if it fires
660
+ // mid-install on Windows it grabs file handles and triggers EBUSY.
661
+ // POSIX systems can replace open files freely, but stopping early there
662
+ // too is harmless and gives a cleaner restart sequence — so we do it
663
+ // everywhere. Tolerate "already stopped" failures silently.
664
+ info('Stopping services so npm install can replace files...');
590
665
  if (IS_WIN) {
591
- info('Stopping service so npm install can replace files...');
592
666
  stopWindowsTask(WIN_LABELS.server);
593
- stoppedForUpdate = true;
667
+ stopWindowsTask(WIN_LABELS.auth);
594
668
  } else if (IS_MAC) {
595
- const p = plistPathForLabel(SERVER_LABEL);
596
- launchctlUnload(p);
597
- stoppedForUpdate = true;
669
+ launchctlUnload(plistPathForLabel(SERVER_LABEL));
670
+ launchctlUnload(plistPathForLabel(AUTH_LABEL));
598
671
  } else if (IS_LINUX) {
599
672
  stopLinuxUnit(LINUX_UNITS.server);
600
- stoppedForUpdate = true;
673
+ if (LINUX_UNITS.timer) stopLinuxUnit(LINUX_UNITS.timer);
674
+ if (LINUX_UNITS.auth) stopLinuxUnit(LINUX_UNITS.auth);
601
675
  }
602
676
 
603
677
  // ---- Perform the upgrade
@@ -618,40 +692,193 @@ async function cmdUpdate() {
618
692
  return die(`Install mode is "${mode}" — can't auto-update. Reinstall via npm or git.`);
619
693
  }
620
694
 
621
- // ---- Bring the service back up on the new code
695
+ // ---- Bring services back up on the new code (server first, then
696
+ // auth-refresh — server is the load-bearing one; auth restart is
697
+ // best-effort since it'll naturally fire on its next interval anyway).
622
698
  section('Restart');
623
- info('Starting service on the new build...');
699
+ info('Starting services on the new build...');
624
700
  if (IS_MAC) {
625
- const p = plistPathForLabel(SERVER_LABEL);
626
- launchctlLoad(p);
701
+ launchctlLoad(plistPathForLabel(SERVER_LABEL));
627
702
  ok(`Loaded ${SERVER_LABEL}`);
703
+ try { launchctlLoad(plistPathForLabel(AUTH_LABEL)); ok(`Loaded ${AUTH_LABEL}`); } catch {}
628
704
  } else if (IS_WIN) {
629
705
  startWindowsTask(WIN_LABELS.server);
630
706
  ok(`Started ${WIN_LABELS.server}`);
707
+ try { startWindowsTask(WIN_LABELS.auth); ok(`Started ${WIN_LABELS.auth}`); } catch {}
631
708
  } else if (IS_LINUX) {
632
709
  startLinuxUnit(LINUX_UNITS.server);
633
710
  ok(`Started ${LINUX_UNITS.server}`);
711
+ if (LINUX_UNITS.timer) { try { startLinuxUnit(LINUX_UNITS.timer); ok(`Started ${LINUX_UNITS.timer}`); } catch {} }
634
712
  }
635
713
  print('');
636
714
  info(`Tip: if the install-layout changed (new service file, new paths), run \`mobygate init\` to re-install the service definitions.`);
637
715
  }
638
716
 
717
+ // ---------------------------------------------------------------------------
718
+ // `mobygate connect` — auto-wire third-party clients to use mobygate.
719
+ //
720
+ // Detects supported clients (Hermes, OpenClaw) on the system, plans the
721
+ // minimal config edit to register `moby` (OpenAI-compat) and/or
722
+ // `moby-native` (Anthropic-messages) as a provider, and applies it
723
+ // atomically with a backup. Idempotent — running twice doesn't duplicate
724
+ // entries; running with --dry-run shows the planned diff without writing.
725
+ //
726
+ // Per-client adapters live in lib/connectors/<id>.js and follow a
727
+ // uniform contract (detect/inspect/plan/apply/disconnect). Add a new
728
+ // connector by writing the adapter and registering it in
729
+ // lib/connectors/index.js#CONNECTORS.
730
+ // ---------------------------------------------------------------------------
731
+
732
+ function parseConnectArgs() {
733
+ // process.argv is [node, mobygate, connect, ...rest]
734
+ const rest = process.argv.slice(3);
735
+ const flags = new Set(rest.filter((a) => a.startsWith('-')));
736
+ const positional = rest.filter((a) => !a.startsWith('-'));
737
+ return {
738
+ targetId: positional[0] || null,
739
+ dryRun: flags.has('--dry-run'),
740
+ yes: flags.has('--yes') || flags.has('-y'),
741
+ all: flags.has('--all'),
742
+ noDefault: flags.has('--no-default'),
743
+ };
744
+ }
745
+
746
+ async function cmdConnect() {
747
+ const { targetId, dryRun, yes, all, noDefault } = parseConnectArgs();
748
+ section('mobygate connect');
749
+ if (dryRun) info('Dry-run mode — no files will be written.');
750
+
751
+ const detected = await detectAllConnectors();
752
+ const present = detected.filter((d) => d.detection !== null);
753
+
754
+ if (present.length === 0) {
755
+ print(c.dim('No supported clients detected. Currently we recognize:'));
756
+ for (const conn of CONNECTORS) {
757
+ print(c.dim(` - ${conn.displayName}`));
758
+ }
759
+ print(c.dim('Install one and re-run `mobygate connect`.'));
760
+ return;
761
+ }
762
+
763
+ // Decide which clients to act on.
764
+ let targets;
765
+ if (targetId) {
766
+ const match = present.find((p) => p.connector.id === targetId.toLowerCase());
767
+ if (!match) return die(`Client "${targetId}" not detected. Detected: ${present.map((p) => p.connector.id).join(', ')}`);
768
+ targets = [match];
769
+ } else if (all || yes) {
770
+ targets = present;
771
+ } else {
772
+ print('');
773
+ print('Detected clients:');
774
+ for (const { connector, detection } of present) {
775
+ print(` ${c.green('✓')} ${c.bold(connector.displayName)} ${c.dim('at ' + detection.configPath)}`);
776
+ }
777
+ print('');
778
+ const which = await prompt('Wire all? (Y / n / comma-separated ids)', 'Y');
779
+ const trimmed = which.trim().toLowerCase();
780
+ if (trimmed === 'n' || trimmed === 'no') return ok('Aborted — nothing changed.');
781
+ if (!trimmed || trimmed === 'y' || trimmed === 'yes') {
782
+ targets = present;
783
+ } else {
784
+ const ids = trimmed.split(',').map((s) => s.trim()).filter(Boolean);
785
+ targets = present.filter((p) => ids.includes(p.connector.id));
786
+ if (targets.length === 0) return die(`No detected clients matched "${which}".`);
787
+ }
788
+ }
789
+
790
+ // Plan + (maybe) apply per target.
791
+ for (const { connector } of targets) {
792
+ print('');
793
+ section(connector.displayName);
794
+ let plan;
795
+ try {
796
+ plan = await connector.plan({
797
+ baseUrl: MOBY_BASE_URL,
798
+ apiKey: MOBY_API_KEY,
799
+ setDefault: !noDefault,
800
+ });
801
+ } catch (e) {
802
+ warn(`plan failed: ${e.message}`);
803
+ continue;
804
+ }
805
+ if (plan.skip) { warn(plan.reason); continue; }
806
+
807
+ print(c.dim(`config: ${plan.configPath}`));
808
+ print('Changes:');
809
+ for (const line of plan.summary) {
810
+ const colored = line.startsWith('+') ? c.green(line) : line.startsWith('-') ? c.red(line) : c.cyan(line);
811
+ print(' ' + colored);
812
+ }
813
+ for (const w of plan.warnings || []) warn(w);
814
+
815
+ if (dryRun) { info('Dry-run — skipping write.'); continue; }
816
+
817
+ if (!yes) {
818
+ const ok2 = await confirm('Apply this change?', true);
819
+ if (!ok2) { warn('Skipped.'); continue; }
820
+ }
821
+
822
+ try {
823
+ const result = await connector.apply(plan);
824
+ if (result.applied) {
825
+ ok(`Wrote ${result.bytesWritten} bytes → ${result.configPath}`);
826
+ if (result.backupPath) print(c.dim(` backup: ${result.backupPath}`));
827
+ info(`Restart ${connector.displayName} so it picks up the new provider.`);
828
+ } else {
829
+ warn(`Skipped: ${result.reason}`);
830
+ }
831
+ } catch (e) {
832
+ die(`apply failed: ${e.message}`);
833
+ }
834
+ }
835
+ }
836
+
837
+ async function cmdDisconnect() {
838
+ const targetId = process.argv[3];
839
+ if (!targetId) return die('Usage: mobygate disconnect <client-id>\nKnown ids: ' + CONNECTORS.map((c) => c.id).join(', '));
840
+ const conn = getConnector(targetId.toLowerCase());
841
+ if (!conn) return die(`No connector for "${targetId}". Known: ${CONNECTORS.map((c) => c.id).join(', ')}`);
842
+ section(`mobygate disconnect ${conn.displayName}`);
843
+ let result;
844
+ try {
845
+ result = await conn.disconnect();
846
+ } catch (e) {
847
+ return die(`disconnect failed: ${e.message}`);
848
+ }
849
+ if (result.applied) {
850
+ ok(`Removed moby entries from ${conn.displayName}`);
851
+ if (result.backupPath) print(c.dim(` backup: ${result.backupPath}`));
852
+ if (result.note) info(result.note);
853
+ info(`Restart ${conn.displayName} so the change takes effect.`);
854
+ } else {
855
+ warn(result.reason);
856
+ }
857
+ }
858
+
639
859
  function usage() {
640
860
  print(`mobygate — OpenAI → Claude Max local gateway
641
861
 
642
862
  Usage:
643
- mobygate init Interactive setup (add --yes to skip prompts,
644
- --no-browser to not auto-open the dashboard)
645
- mobygate update Upgrade to the latest version + restart service
646
- mobygate doctor Diagnose version mismatches, zombie services, port conflicts
647
- mobygate start Start the proxy service
648
- mobygate stop Stop the proxy service
649
- mobygate restart Stop + start
650
- mobygate status Show service + auth + /health state
651
- mobygate logs Tail the server log
652
- mobygate auth Show auth status + run a refresh probe
653
- mobygate uninstall Remove installed services
654
- mobygate version Print version + install mode + path
863
+ mobygate init Interactive setup (add --yes to skip prompts,
864
+ --no-browser to not auto-open the dashboard)
865
+ mobygate update Upgrade to the latest version + restart service
866
+ mobygate doctor Diagnose version mismatches, zombie services, port conflicts
867
+ mobygate start Start the proxy service
868
+ mobygate stop Stop the proxy service
869
+ mobygate restart Stop + start
870
+ mobygate status Show service + auth + /health state
871
+ mobygate logs Tail the server log
872
+ mobygate auth Show auth status + run a refresh probe
873
+ mobygate connect Auto-wire detected clients (Hermes, OpenClaw) to
874
+ use mobygate. Add: <id> for one client, --all for
875
+ every detected, --dry-run to preview, --yes to
876
+ skip prompts, --no-default to register without
877
+ changing the active model.
878
+ mobygate disconnect Remove moby entries from a client config.
879
+ Usage: mobygate disconnect <client-id>
880
+ mobygate uninstall Remove installed services
881
+ mobygate version Print version + install mode + path
655
882
 
656
883
  Config: ~/.mobygate/config.yaml (env vars override)
657
884
  Repo: ${REPO_ROOT}
@@ -663,6 +890,8 @@ Repo: ${REPO_ROOT}
663
890
  const cmd = process.argv[2];
664
891
  const COMMANDS = {
665
892
  init: cmdInit,
893
+ connect: cmdConnect,
894
+ disconnect: cmdDisconnect,
666
895
  update: cmdUpdate,
667
896
  upgrade: cmdUpdate,
668
897
  doctor: cmdDoctor,
package/lib/anthropic.js CHANGED
@@ -112,15 +112,26 @@ export function anthropicMessagesToPrompt(body, { resuming = false } = {}) {
112
112
  // SDK has full history. Send only the new tail: tool_results from
113
113
  // the last user message (if any) plus any fresh user text.
114
114
  const last = messages[messages.length - 1];
115
- if (!last || last.role !== 'user') return '';
115
+ if (!last || last.role !== 'user') {
116
+ return {
117
+ promptText: '',
118
+ error: 'Resume mode requires the last message to be from the user. Last message has role "' + (last?.role || 'none') + '".',
119
+ };
120
+ }
116
121
  const trBlocks = anthropicToolResultsOf(last.content);
117
122
  const text = anthropicTextOf(last.content);
123
+ if (!trBlocks.length && !text) {
124
+ return {
125
+ promptText: '',
126
+ error: 'Resume mode requires the last user message to contain text or tool_result blocks.',
127
+ };
128
+ }
118
129
  const parts = [];
119
130
  if (trBlocks.length) {
120
131
  parts.push(`<tool_results>\n${trBlocks.map(formatToolResultBlock).join('\n')}\n</tool_results>`);
121
132
  }
122
133
  if (text) parts.push(text);
123
- return parts.join('\n\n');
134
+ return { promptText: parts.join('\n\n') };
124
135
  }
125
136
 
126
137
  // Fresh request: serialize visible history. System prompt at top, then
@@ -154,7 +165,7 @@ export function anthropicMessagesToPrompt(body, { resuming = false } = {}) {
154
165
  }
155
166
  }
156
167
  flushTools();
157
- return parts.join('\n').trim();
168
+ return { promptText: parts.join('\n').trim() };
158
169
  }
159
170
 
160
171
  /**