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 +75 -0
- package/bin/mobygate.js +218 -12
- 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/package.json +1 -1
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
|
|
667
|
-
|
|
668
|
-
mobygate update
|
|
669
|
-
mobygate doctor
|
|
670
|
-
mobygate start
|
|
671
|
-
mobygate stop
|
|
672
|
-
mobygate restart
|
|
673
|
-
mobygate status
|
|
674
|
-
mobygate logs
|
|
675
|
-
mobygate auth
|
|
676
|
-
mobygate
|
|
677
|
-
|
|
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
|
+
}
|