mobygate 0.7.3 → 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,81 @@ 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
+
7
82
  ## [0.7.3] — 2026-04-25
8
83
 
9
84
  Hotfix bundle from a thorough security + bugs + ops audit. Six items.
package/bin/mobygate.js CHANGED
@@ -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), '..');
@@ -280,6 +287,54 @@ async function cmdInit() {
280
287
  info('Start the server manually (see instructions above) then run `mobygate status`.');
281
288
  }
282
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
+
283
338
  section('Done');
284
339
  print(`Dashboard: ${c.cyan(`http://localhost:${port}`)}`);
285
340
  print(`Configure: ${c.cyan(CONFIG_PATH)}`);
@@ -659,22 +714,171 @@ async function cmdUpdate() {
659
714
  info(`Tip: if the install-layout changed (new service file, new paths), run \`mobygate init\` to re-install the service definitions.`);
660
715
  }
661
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
+
662
859
  function usage() {
663
860
  print(`mobygate — OpenAI → Claude Max local gateway
664
861
 
665
862
  Usage:
666
- mobygate init Interactive setup (add --yes to skip prompts,
667
- --no-browser to not auto-open the dashboard)
668
- mobygate update Upgrade to the latest version + restart service
669
- mobygate doctor Diagnose version mismatches, zombie services, port conflicts
670
- mobygate start Start the proxy service
671
- mobygate stop Stop the proxy service
672
- mobygate restart Stop + start
673
- mobygate status Show service + auth + /health state
674
- mobygate logs Tail the server log
675
- mobygate auth Show auth status + run a refresh probe
676
- mobygate uninstall Remove installed services
677
- 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
678
882
 
679
883
  Config: ~/.mobygate/config.yaml (env vars override)
680
884
  Repo: ${REPO_ROOT}
@@ -686,6 +890,8 @@ Repo: ${REPO_ROOT}
686
890
  const cmd = process.argv[2];
687
891
  const COMMANDS = {
688
892
  init: cmdInit,
893
+ connect: cmdConnect,
894
+ disconnect: cmdDisconnect,
689
895
  update: cmdUpdate,
690
896
  upgrade: cmdUpdate,
691
897
  doctor: cmdDoctor,
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Hermes connector.
3
+ *
4
+ * Hermes (the agent harness) lives at `~/.hermes/` with a `config.yaml`
5
+ * holding `model:` (the active model + provider) and `providers:` (a map
6
+ * of named providers keyed by id). Hermes only speaks the OpenAI-compat
7
+ * wire format, so we register a single provider entry: `moby`.
8
+ *
9
+ * Hermes references custom providers via `model.provider: custom:<id>`,
10
+ * so wiring "use mobygate as default" means setting:
11
+ * model.default: claude-opus-4-7
12
+ * model.provider: custom:moby
13
+ *
14
+ * Caveats:
15
+ * - We use js-yaml to parse + re-emit. js-yaml does NOT preserve
16
+ * comments or blank lines. If the user has hand-edited their YAML
17
+ * with comments, those will be lost in the rewritten file. The
18
+ * auto-backup (safety.js) catches this — we also warn on detection.
19
+ * - We only touch `providers.moby` (always) and `model.*` (only with
20
+ * `setDefault: true`). Everything else in the YAML is preserved.
21
+ */
22
+
23
+ import { readFileSync, existsSync } from 'fs';
24
+ import { join } from 'path';
25
+ import { homedir } from 'os';
26
+ import yaml from 'js-yaml';
27
+ import { backup, writeConfigSafe, diffSummary } from './safety.js';
28
+ import { DEFAULT_BASE_URL, DEFAULT_API_KEY, PROVIDER_NAME_OPENAI } from './index.js';
29
+
30
+ const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
31
+ const HERMES_CONFIG = join(HERMES_HOME, 'config.yaml');
32
+
33
+ function buildProviderEntry({ baseUrl, apiKey }) {
34
+ return {
35
+ api: `${baseUrl.replace(/\/$/, '')}/v1`,
36
+ name: 'Moby (Claude Max via mobygate)',
37
+ api_key: apiKey,
38
+ default_model: 'claude-opus-4-7',
39
+ };
40
+ }
41
+
42
+ export const hermesConnector = {
43
+ id: 'hermes',
44
+ displayName: 'Hermes',
45
+
46
+ async detect() {
47
+ if (!existsSync(HERMES_CONFIG)) return null;
48
+ let raw;
49
+ try {
50
+ raw = readFileSync(HERMES_CONFIG, 'utf8');
51
+ } catch (e) {
52
+ return null;
53
+ }
54
+ let parsed;
55
+ try {
56
+ parsed = yaml.load(raw);
57
+ } catch (e) {
58
+ // Config exists but is unparseable — surface that to the user via
59
+ // detection metadata rather than silently treating as not-installed.
60
+ return { configPath: HERMES_CONFIG, parseError: e.message };
61
+ }
62
+ return {
63
+ configPath: HERMES_CONFIG,
64
+ version: parsed?._config_version ?? null,
65
+ hasComments: /(^|\n)\s*#/.test(raw),
66
+ parsed,
67
+ };
68
+ },
69
+
70
+ async inspect() {
71
+ const det = await this.detect();
72
+ if (!det) return { installed: false };
73
+ if (det.parseError) return { installed: true, parseError: det.parseError };
74
+
75
+ const providers = det.parsed?.providers || {};
76
+ const existing = providers[PROVIDER_NAME_OPENAI];
77
+ const currentDefault = det.parsed?.model?.provider || null;
78
+ return {
79
+ installed: true,
80
+ configPath: det.configPath,
81
+ mobyProviderExists: !!existing,
82
+ mobyProviderMatches: existing
83
+ ? JSON.stringify(existing) === JSON.stringify(buildProviderEntry({
84
+ baseUrl: existing.api?.replace(/\/v1$/, '') || DEFAULT_BASE_URL,
85
+ apiKey: existing.api_key,
86
+ }))
87
+ : false,
88
+ currentDefaultProvider: currentDefault,
89
+ hasComments: det.hasComments,
90
+ };
91
+ },
92
+
93
+ async plan({ baseUrl = DEFAULT_BASE_URL, apiKey = DEFAULT_API_KEY, setDefault = true } = {}) {
94
+ const det = await this.detect();
95
+ if (!det) {
96
+ return { skip: true, reason: 'Hermes not detected (no ~/.hermes/config.yaml)' };
97
+ }
98
+ if (det.parseError) {
99
+ return { skip: true, reason: `Hermes config is unparseable: ${det.parseError}` };
100
+ }
101
+
102
+ const before = det.parsed || {};
103
+ const after = JSON.parse(JSON.stringify(before)); // deep clone
104
+
105
+ if (!after.providers) after.providers = {};
106
+ after.providers[PROVIDER_NAME_OPENAI] = buildProviderEntry({ baseUrl, apiKey });
107
+
108
+ if (setDefault) {
109
+ if (!after.model) after.model = {};
110
+ after.model.default = after.model.default || 'claude-opus-4-7';
111
+ after.model.provider = `custom:${PROVIDER_NAME_OPENAI}`;
112
+ after.model.context_length = after.model.context_length || 1000000;
113
+ }
114
+
115
+ const summary = diffSummary(
116
+ { providers: before.providers, model: before.model },
117
+ { providers: after.providers, model: after.model },
118
+ );
119
+
120
+ return {
121
+ skip: false,
122
+ configPath: det.configPath,
123
+ before,
124
+ after,
125
+ summary,
126
+ warnings: det.hasComments
127
+ ? ['Your config.yaml contains comments. js-yaml will drop them on re-emit. ' +
128
+ 'A timestamped backup is saved before the write — restore from it if needed.']
129
+ : [],
130
+ };
131
+ },
132
+
133
+ async apply(plan) {
134
+ if (plan.skip) return { applied: false, reason: plan.reason };
135
+ // js-yaml emit options: quote strings that need it, but use block
136
+ // style for maps (keeps it readable, matches Hermes's existing style).
137
+ const yamlOut = yaml.dump(plan.after, {
138
+ indent: 2,
139
+ lineWidth: 0,
140
+ noRefs: true,
141
+ sortKeys: false,
142
+ });
143
+ const result = writeConfigSafe(plan.configPath, yamlOut);
144
+ return {
145
+ applied: true,
146
+ configPath: result.path,
147
+ backupPath: result.backupPath,
148
+ bytesWritten: result.bytesWritten,
149
+ };
150
+ },
151
+
152
+ async disconnect() {
153
+ const det = await this.detect();
154
+ if (!det) return { applied: false, reason: 'Hermes not installed' };
155
+ if (det.parseError) return { applied: false, reason: `parse error: ${det.parseError}` };
156
+ const before = det.parsed || {};
157
+ const after = JSON.parse(JSON.stringify(before));
158
+ let changed = false;
159
+
160
+ if (after.providers && after.providers[PROVIDER_NAME_OPENAI]) {
161
+ delete after.providers[PROVIDER_NAME_OPENAI];
162
+ changed = true;
163
+ }
164
+ // Reset default provider if it was pointing at us.
165
+ if (after.model?.provider === `custom:${PROVIDER_NAME_OPENAI}`) {
166
+ // Don't pick a replacement — leave it for the user to re-set.
167
+ // Better than silently switching them to anthropic-direct (which
168
+ // would burn API tokens) or whatever else we'd guess.
169
+ after.model.provider = 'anthropic';
170
+ changed = true;
171
+ }
172
+
173
+ if (!changed) return { applied: false, reason: 'No moby provider entry in Hermes config' };
174
+
175
+ const yamlOut = yaml.dump(after, { indent: 2, lineWidth: 0, noRefs: true, sortKeys: false });
176
+ const result = writeConfigSafe(det.configPath, yamlOut);
177
+ return {
178
+ applied: true,
179
+ configPath: result.path,
180
+ backupPath: result.backupPath,
181
+ note: after.model?.provider === 'anthropic'
182
+ ? 'Reset model.provider to "anthropic" — verify ANTHROPIC_TOKEN is set if you intend to use direct API.'
183
+ : null,
184
+ };
185
+ },
186
+ };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Client connector registry + orchestrator.
3
+ *
4
+ * Connectors auto-wire third-party clients (Hermes, OpenClaw, etc.) to
5
+ * use mobygate as their inference provider. This avoids the manual
6
+ * "find your client's config file → paste this JSON snippet → restart"
7
+ * dance for each client a user wants to connect.
8
+ *
9
+ * Each connector lives in `lib/connectors/<id>.js` and exports a
10
+ * uniform contract:
11
+ *
12
+ * - id — short stable identifier (e.g. 'hermes', 'openclaw')
13
+ * - displayName — human-readable name for prompts/logs
14
+ * - detect() — probe for the client; returns DetectionResult | null
15
+ * - inspect() — read current config; returns InspectionResult
16
+ * - plan(opts) — compute the diff to apply; returns Plan
17
+ * - apply(plan) — perform the modification atomically
18
+ * - disconnect() — remove our entries cleanly
19
+ *
20
+ * Branding: all provider entries we register use the `moby` prefix to
21
+ * make them visually identifiable. Two flavors:
22
+ * - `moby` — OpenAI-compat surface (POST /v1/chat/completions)
23
+ * - `moby-native` — Anthropic-messages surface (POST /v1/messages)
24
+ *
25
+ * Clients that only handle one wire format get whichever fits; clients
26
+ * that handle both get both registered, with `moby-native` as the
27
+ * preferred default.
28
+ */
29
+
30
+ import { hermesConnector } from './hermes.js';
31
+ import { openclawConnector } from './openclaw.js';
32
+
33
+ // Public API for any caller that wants to know where mobygate is reachable.
34
+ // Defaults match server.js's defaults; init can override via opts.
35
+ export const DEFAULT_BASE_URL = 'http://127.0.0.1:3456';
36
+ export const DEFAULT_API_KEY = 'claude-max';
37
+
38
+ // Branded provider names. Short, distinct, unmistakably from mobygate.
39
+ export const PROVIDER_NAME_OPENAI = 'moby';
40
+ export const PROVIDER_NAME_ANTHROPIC = 'moby-native';
41
+
42
+ /**
43
+ * All registered connectors, in display order. Add new ones here.
44
+ * v0.8.0 ships with hermes + openclaw; pi-agent and others are on the
45
+ * v0.8.x backlog.
46
+ */
47
+ export const CONNECTORS = [
48
+ hermesConnector,
49
+ openclawConnector,
50
+ ];
51
+
52
+ /**
53
+ * Run detection across every registered connector. Returns an array of
54
+ * { connector, detection } where detection is the connector's
55
+ * DetectionResult (or null if not found). Connectors that throw are
56
+ * treated as "not detected" — we never let one broken adapter break
57
+ * the whole orchestrator.
58
+ */
59
+ export async function detectAll() {
60
+ const out = [];
61
+ for (const c of CONNECTORS) {
62
+ let detection = null;
63
+ try {
64
+ detection = await c.detect();
65
+ } catch (e) {
66
+ // Detection should be silent on failure — the client may simply
67
+ // not be installed. Don't surface noise.
68
+ detection = null;
69
+ }
70
+ out.push({ connector: c, detection });
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Convenience: get a connector by id, or null.
77
+ */
78
+ export function getConnector(id) {
79
+ return CONNECTORS.find((c) => c.id === id) || null;
80
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * OpenClaw connector.
3
+ *
4
+ * OpenClaw is the Discord-bot agent harness. Its config lives at
5
+ * `~/.openclaw/openclaw.json` (canonical on Linux/Mac, mirrored as
6
+ * `%USERPROFILE%\.openclaw\openclaw.json` on Windows — verified
7
+ * against a real Geekom install).
8
+ *
9
+ * Unlike Hermes, OpenClaw understands BOTH wire formats — `openai-completions`
10
+ * (for legacy OpenAI-shape providers) and `anthropic-messages` (for
11
+ * Anthropic-native providers, which unlocks vision + native tools +
12
+ * thinking blocks). So we register both surfaces:
13
+ * - moby (api: openai-completions → /v1/chat/completions)
14
+ * - moby-native (api: anthropic-messages → /v1/messages)
15
+ *
16
+ * `moby-native` is set as the main + default model when setDefault is
17
+ * true — that's the wire format that gives OpenClaw the full feature
18
+ * set. `moby` stays available as fallback / OpenAI-compat clients.
19
+ *
20
+ * Config schema (inferred from the user's Geekom config):
21
+ * {
22
+ * "models": {
23
+ * "main": "<provider>/<model-id>",
24
+ * "default": "<provider>/<model-id>",
25
+ * "providers": {
26
+ * "<provider-id>": {
27
+ * "baseUrl": "...",
28
+ * "apiKey": "...",
29
+ * "api": "openai-completions" | "anthropic-messages",
30
+ * "models": [ { id, name, contextWindow, maxTokens, input, reasoning, cost }, ... ]
31
+ * }
32
+ * }
33
+ * }
34
+ * }
35
+ */
36
+
37
+ import { readFileSync, existsSync } from 'fs';
38
+ import { join } from 'path';
39
+ import { homedir } from 'os';
40
+ import { writeConfigSafe, diffSummary } from './safety.js';
41
+ import {
42
+ DEFAULT_BASE_URL,
43
+ DEFAULT_API_KEY,
44
+ PROVIDER_NAME_OPENAI,
45
+ PROVIDER_NAME_ANTHROPIC,
46
+ } from './index.js';
47
+
48
+ const OPENCLAW_HOME = process.env.OPENCLAW_HOME || join(homedir(), '.openclaw');
49
+ const OPENCLAW_CONFIG = join(OPENCLAW_HOME, 'openclaw.json');
50
+
51
+ // Probe order — first match wins. Lets us support installs that put the
52
+ // config somewhere other than ~/.openclaw/.
53
+ const CONFIG_PROBES = [
54
+ OPENCLAW_CONFIG,
55
+ // Add other plausible paths here as they're discovered. Empty list is
56
+ // fine for v0.8.0 — every install we've seen uses ~/.openclaw/.
57
+ ];
58
+
59
+ const MODELS_OPENAI_SURFACE = [
60
+ { id: 'claude-opus-4-7', name: 'Claude Opus 4.7 (Max via Moby)', contextWindow: 1000000, maxTokens: 32768, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
61
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
62
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
63
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (Max via Moby)', contextWindow: 200000, maxTokens: 16384, input: ['text'], reasoning: false, cost: { input: 0, output: 0 } },
64
+ ];
65
+
66
+ // Native surface declares text+image input and reasoning capability so
67
+ // OpenClaw will send vision content and surface thinking blocks.
68
+ const MODELS_NATIVE_SURFACE = [
69
+ { id: 'claude-opus-4-7', name: 'Claude Opus 4.7 (Max, native)', contextWindow: 1000000, maxTokens: 32768, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
70
+ { id: 'claude-opus-4-6', name: 'Claude Opus 4.6 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
71
+ { id: 'claude-sonnet-4-6', name: 'Claude Sonnet 4.6 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
72
+ { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (Max, native)', contextWindow: 200000, maxTokens: 16384, input: ['text', 'image'], reasoning: true, cost: { input: 0, output: 0 } },
73
+ ];
74
+
75
+ function buildOpenAIProvider({ baseUrl, apiKey }) {
76
+ return {
77
+ baseUrl,
78
+ apiKey,
79
+ api: 'openai-completions',
80
+ models: MODELS_OPENAI_SURFACE,
81
+ };
82
+ }
83
+
84
+ function buildNativeProvider({ baseUrl, apiKey }) {
85
+ return {
86
+ baseUrl,
87
+ apiKey,
88
+ api: 'anthropic-messages',
89
+ models: MODELS_NATIVE_SURFACE,
90
+ };
91
+ }
92
+
93
+ function findConfigPath() {
94
+ for (const p of CONFIG_PROBES) {
95
+ if (existsSync(p)) return p;
96
+ }
97
+ return null;
98
+ }
99
+
100
+ function isMobyDefaultPointer(s) {
101
+ if (typeof s !== 'string') return false;
102
+ return s.startsWith(`${PROVIDER_NAME_OPENAI}/`) || s.startsWith(`${PROVIDER_NAME_ANTHROPIC}/`);
103
+ }
104
+
105
+ export const openclawConnector = {
106
+ id: 'openclaw',
107
+ displayName: 'OpenClaw',
108
+
109
+ async detect() {
110
+ const configPath = findConfigPath();
111
+ if (!configPath) return null;
112
+ let raw;
113
+ try {
114
+ raw = readFileSync(configPath, 'utf8');
115
+ } catch (e) {
116
+ return null;
117
+ }
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(raw);
121
+ } catch (e) {
122
+ return { configPath, parseError: e.message };
123
+ }
124
+ return { configPath, parsed };
125
+ },
126
+
127
+ async inspect() {
128
+ const det = await this.detect();
129
+ if (!det) return { installed: false };
130
+ if (det.parseError) return { installed: true, parseError: det.parseError };
131
+
132
+ const providers = det.parsed?.models?.providers || {};
133
+ return {
134
+ installed: true,
135
+ configPath: det.configPath,
136
+ mobyProviderExists: !!providers[PROVIDER_NAME_OPENAI],
137
+ mobyNativeProviderExists: !!providers[PROVIDER_NAME_ANTHROPIC],
138
+ currentMain: det.parsed?.models?.main || null,
139
+ currentDefault: det.parsed?.models?.default || null,
140
+ };
141
+ },
142
+
143
+ async plan({
144
+ baseUrl = DEFAULT_BASE_URL,
145
+ apiKey = DEFAULT_API_KEY,
146
+ setDefault = true,
147
+ registerOpenAISurface = true,
148
+ registerNativeSurface = true,
149
+ } = {}) {
150
+ const det = await this.detect();
151
+ if (!det) {
152
+ return { skip: true, reason: 'OpenClaw not detected (no ~/.openclaw/openclaw.json)' };
153
+ }
154
+ if (det.parseError) {
155
+ return { skip: true, reason: `OpenClaw config is unparseable JSON: ${det.parseError}` };
156
+ }
157
+
158
+ const before = det.parsed || {};
159
+ const after = JSON.parse(JSON.stringify(before)); // deep clone
160
+
161
+ if (!after.models) after.models = {};
162
+ if (!after.models.providers) after.models.providers = {};
163
+
164
+ if (registerOpenAISurface) {
165
+ after.models.providers[PROVIDER_NAME_OPENAI] = buildOpenAIProvider({ baseUrl, apiKey });
166
+ }
167
+ if (registerNativeSurface) {
168
+ after.models.providers[PROVIDER_NAME_ANTHROPIC] = buildNativeProvider({ baseUrl, apiKey });
169
+ }
170
+
171
+ if (setDefault) {
172
+ // Prefer native if registered; fall back to openai-compat otherwise.
173
+ const preferredProvider = registerNativeSurface
174
+ ? PROVIDER_NAME_ANTHROPIC
175
+ : registerOpenAISurface
176
+ ? PROVIDER_NAME_OPENAI
177
+ : null;
178
+ if (preferredProvider) {
179
+ const target = `${preferredProvider}/claude-opus-4-7`;
180
+ after.models.main = target;
181
+ after.models.default = target;
182
+ }
183
+ }
184
+
185
+ const summary = diffSummary(
186
+ { providers: before.models?.providers, main: before.models?.main, default: before.models?.default },
187
+ { providers: after.models.providers, main: after.models.main, default: after.models.default },
188
+ );
189
+
190
+ return {
191
+ skip: false,
192
+ configPath: det.configPath,
193
+ before,
194
+ after,
195
+ summary,
196
+ warnings: [],
197
+ };
198
+ },
199
+
200
+ async apply(plan) {
201
+ if (plan.skip) return { applied: false, reason: plan.reason };
202
+ // OpenClaw uses 2-space indented JSON in its own config writes.
203
+ // Match that style so subsequent self-writes don't reformat the
204
+ // whole file.
205
+ const jsonOut = JSON.stringify(plan.after, null, 2) + '\n';
206
+ const result = writeConfigSafe(plan.configPath, jsonOut);
207
+ return {
208
+ applied: true,
209
+ configPath: result.path,
210
+ backupPath: result.backupPath,
211
+ bytesWritten: result.bytesWritten,
212
+ };
213
+ },
214
+
215
+ async disconnect() {
216
+ const det = await this.detect();
217
+ if (!det) return { applied: false, reason: 'OpenClaw not installed' };
218
+ if (det.parseError) return { applied: false, reason: `parse error: ${det.parseError}` };
219
+ const before = det.parsed || {};
220
+ const after = JSON.parse(JSON.stringify(before));
221
+ let changed = false;
222
+
223
+ const providers = after.models?.providers;
224
+ if (providers) {
225
+ for (const name of [PROVIDER_NAME_OPENAI, PROVIDER_NAME_ANTHROPIC]) {
226
+ if (providers[name]) { delete providers[name]; changed = true; }
227
+ }
228
+ }
229
+ // If main/default was pointing at us, blank them — let the user
230
+ // re-pick rather than guess at a replacement.
231
+ if (isMobyDefaultPointer(after.models?.main)) {
232
+ after.models.main = null;
233
+ changed = true;
234
+ }
235
+ if (isMobyDefaultPointer(after.models?.default)) {
236
+ after.models.default = null;
237
+ changed = true;
238
+ }
239
+
240
+ if (!changed) return { applied: false, reason: 'No moby provider entries in OpenClaw config' };
241
+
242
+ const jsonOut = JSON.stringify(after, null, 2) + '\n';
243
+ const result = writeConfigSafe(det.configPath, jsonOut);
244
+ return {
245
+ applied: true,
246
+ configPath: result.path,
247
+ backupPath: result.backupPath,
248
+ note: (after.models?.main === null || after.models?.default === null)
249
+ ? 'Reset main/default model to null — set a new model in OpenClaw before next request.'
250
+ : null,
251
+ };
252
+ },
253
+ };
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Safety primitives for connector adapters.
3
+ *
4
+ * Every connector that modifies a third-party config file goes through
5
+ * these helpers — they enforce backup, atomic write, and a couple of
6
+ * sanity guards. Adapters MUST NOT call writeFileSync directly on a
7
+ * user's config; they MUST go through `writeConfigSafe`.
8
+ *
9
+ * The contract:
10
+ * 1. Always back up first to `<file>.mobygate-backup-<ISO-timestamp>`.
11
+ * 2. Write to a temp file in the same directory, fsync, then rename.
12
+ * Atomic rename on POSIX, near-atomic on Windows (NTFS handles it
13
+ * transparently for same-volume renames).
14
+ * 3. Verify the rename produced the expected content (read-back +
15
+ * length sanity check) before declaring success.
16
+ *
17
+ * This keeps a corrupt file or partial write from destroying a user's
18
+ * carefully-tuned client config.
19
+ */
20
+
21
+ import {
22
+ readFileSync,
23
+ writeFileSync,
24
+ renameSync,
25
+ copyFileSync,
26
+ existsSync,
27
+ statSync,
28
+ mkdirSync,
29
+ unlinkSync,
30
+ } from 'fs';
31
+ import { dirname, basename, join } from 'path';
32
+
33
+ const ISO_SAFE = (d = new Date()) => d.toISOString().replace(/[:.]/g, '-');
34
+
35
+ /**
36
+ * Make a timestamped backup of `path`. Returns the backup path.
37
+ * No-op (returns null) if `path` doesn't exist — this lets adapters
38
+ * call `backup()` unconditionally even when they're creating a new file.
39
+ */
40
+ export function backup(path) {
41
+ if (!existsSync(path)) return null;
42
+ const dir = dirname(path);
43
+ const name = basename(path);
44
+ const backupPath = join(dir, `${name}.mobygate-backup-${ISO_SAFE()}`);
45
+ copyFileSync(path, backupPath);
46
+ return backupPath;
47
+ }
48
+
49
+ /**
50
+ * Atomically write `content` to `path`. Backs up first. Returns
51
+ * `{ path, backupPath, bytesWritten }` on success; throws on any failure
52
+ * (the original file is preserved by the backup).
53
+ *
54
+ * `content` must be a string. Adapters that work in structured formats
55
+ * (YAML, JSON) serialize before calling this.
56
+ */
57
+ export function writeConfigSafe(path, content) {
58
+ if (typeof content !== 'string') {
59
+ throw new Error(`writeConfigSafe: content must be a string (got ${typeof content})`);
60
+ }
61
+ if (content.length === 0) {
62
+ throw new Error(`writeConfigSafe: refusing to write empty content to ${path}`);
63
+ }
64
+ const dir = dirname(path);
65
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
66
+
67
+ const backupPath = backup(path);
68
+ const tempPath = `${path}.mobygate-tmp-${ISO_SAFE()}`;
69
+
70
+ try {
71
+ writeFileSync(tempPath, content, 'utf8');
72
+ // Sanity check: the temp file should be the size we just wrote.
73
+ const size = statSync(tempPath).size;
74
+ if (size === 0) throw new Error('temp file is empty after write');
75
+ renameSync(tempPath, path);
76
+ } catch (e) {
77
+ // Best-effort cleanup of the temp file. If rename failed mid-flight
78
+ // (rare), the original is intact via the backup.
79
+ try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch {}
80
+ throw new Error(`writeConfigSafe failed for ${path}: ${e.message}` +
81
+ (backupPath ? ` (original preserved at ${backupPath})` : ''));
82
+ }
83
+
84
+ // Final verify: read back what's on disk and confirm it matches.
85
+ const onDisk = readFileSync(path, 'utf8');
86
+ if (onDisk !== content) {
87
+ throw new Error(`writeConfigSafe verify failed for ${path}: ` +
88
+ `read-back differs from intended content` +
89
+ (backupPath ? ` (original preserved at ${backupPath})` : ''));
90
+ }
91
+
92
+ return { path, backupPath, bytesWritten: Buffer.byteLength(content, 'utf8') };
93
+ }
94
+
95
+ /**
96
+ * Compute a human-readable summary of a planned change. Used by adapters
97
+ * to produce dry-run output. `before` and `after` are arbitrary objects;
98
+ * we don't try to be clever — just a count of top-level differences.
99
+ *
100
+ * Returns lines like:
101
+ * + providers.moby (added)
102
+ * ~ providers.moby-native (changed)
103
+ * - providers.old-thing (removed)
104
+ */
105
+ export function diffSummary(before, after, prefix = '') {
106
+ const lines = [];
107
+ const beforeKeys = new Set(Object.keys(before || {}));
108
+ const afterKeys = new Set(Object.keys(after || {}));
109
+ for (const k of afterKeys) {
110
+ const fullKey = prefix ? `${prefix}.${k}` : k;
111
+ if (!beforeKeys.has(k)) {
112
+ lines.push(`+ ${fullKey} (added)`);
113
+ } else if (JSON.stringify(before[k]) !== JSON.stringify(after[k])) {
114
+ lines.push(`~ ${fullKey} (changed)`);
115
+ }
116
+ }
117
+ for (const k of beforeKeys) {
118
+ if (!afterKeys.has(k)) {
119
+ const fullKey = prefix ? `${prefix}.${k}` : k;
120
+ lines.push(`- ${fullKey} (removed)`);
121
+ }
122
+ }
123
+ return lines;
124
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.7.3",
3
+ "version": "0.8.0",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",