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 +145 -0
- package/bin/mobygate.js +282 -53
- package/lib/anthropic.js +14 -3
- package/lib/connectors/hermes.js +186 -0
- package/lib/connectors/index.js +80 -0
- package/lib/connectors/openclaw.js +253 -0
- package/lib/connectors/safety.js +124 -0
- package/lib/platform.js +74 -0
- package/lib/updater.js +38 -20
- package/package.json +1 -1
- package/server.js +131 -17
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
|
|
208
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
325
|
-
|
|
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
|
|
329
|
-
|
|
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
|
|
333
|
-
if (
|
|
334
|
-
|
|
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
|
|
585
|
-
//
|
|
586
|
-
//
|
|
587
|
-
// can replace open files freely, but stopping early there
|
|
588
|
-
// and gives a cleaner restart sequence — so we do it
|
|
589
|
-
|
|
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
|
-
|
|
667
|
+
stopWindowsTask(WIN_LABELS.auth);
|
|
594
668
|
} else if (IS_MAC) {
|
|
595
|
-
|
|
596
|
-
launchctlUnload(
|
|
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
|
-
|
|
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
|
|
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
|
|
699
|
+
info('Starting services on the new build...');
|
|
624
700
|
if (IS_MAC) {
|
|
625
|
-
|
|
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
|
|
644
|
-
|
|
645
|
-
mobygate update
|
|
646
|
-
mobygate doctor
|
|
647
|
-
mobygate start
|
|
648
|
-
mobygate stop
|
|
649
|
-
mobygate restart
|
|
650
|
-
mobygate status
|
|
651
|
-
mobygate logs
|
|
652
|
-
mobygate auth
|
|
653
|
-
mobygate
|
|
654
|
-
|
|
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')
|
|
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
|
/**
|