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
|
@@ -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
|
+
}
|