clawtamer 0.1.1
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/README.md +114 -0
- package/bin/clawtamer.js +1205 -0
- package/package.json +21 -0
- package/plugin/index.js +164 -0
- package/plugin/openclaw.plugin.json +8 -0
- package/src/config.js +7 -0
package/bin/clawtamer.js
ADDED
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const os = require("node:os");
|
|
7
|
+
const { execFileSync } = require("node:child_process");
|
|
8
|
+
const readline = require("node:readline/promises");
|
|
9
|
+
const { stdin, stdout } = require("node:process");
|
|
10
|
+
const { API_BASE, OPENCLAW_DIR_NAME } = require("../src/config");
|
|
11
|
+
|
|
12
|
+
const HELP = `
|
|
13
|
+
Usage: clawtamer [command] [options]
|
|
14
|
+
|
|
15
|
+
Commands:
|
|
16
|
+
init Setup flow for both new and existing users
|
|
17
|
+
status Show local + remote status
|
|
18
|
+
test Send a test proxy request
|
|
19
|
+
savings [period] Show savings (7d, 30d, lifetime)
|
|
20
|
+
doctor Diagnose setup issues
|
|
21
|
+
store-key Rotate stored Anthropic key
|
|
22
|
+
restore Remove clawtamer config patch
|
|
23
|
+
remove Alias for restore
|
|
24
|
+
|
|
25
|
+
Options:
|
|
26
|
+
--key <key> Back-compat key flag (context dependent)
|
|
27
|
+
--proxy-key <k> Explicit Clawtamer API key (sk-cltm-...)
|
|
28
|
+
--upstream-key <k> Explicit Anthropic key (sk-ant-...)
|
|
29
|
+
--existing-user Skip signup and use existing Clawtamer proxy key flow
|
|
30
|
+
--new-user Force new-user signup flow
|
|
31
|
+
--skip-install-test Skip automatic post-install proxy validation
|
|
32
|
+
--email <email> Email used for signup
|
|
33
|
+
--yes, -y Auto-confirm interactive prompts
|
|
34
|
+
--dry-run Validate signup/preflight but do not edit openclaw.json
|
|
35
|
+
--activate Force activation even if proxy preflight fails
|
|
36
|
+
--api-base <url> Override API base URL
|
|
37
|
+
--dir <path> Override OpenClaw root directory
|
|
38
|
+
--help Show this help
|
|
39
|
+
--version Show version
|
|
40
|
+
`.trim();
|
|
41
|
+
|
|
42
|
+
const USE_COLOR = Boolean(stdout.isTTY) && !process.env.NO_COLOR;
|
|
43
|
+
const ANSI = {
|
|
44
|
+
reset: "\x1b[0m",
|
|
45
|
+
bold: "\x1b[1m",
|
|
46
|
+
dim: "\x1b[2m",
|
|
47
|
+
red: "\x1b[31m",
|
|
48
|
+
green: "\x1b[32m",
|
|
49
|
+
yellow: "\x1b[33m",
|
|
50
|
+
cyan: "\x1b[36m"
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function paint(text, code) {
|
|
54
|
+
if (!USE_COLOR) return String(text);
|
|
55
|
+
return `${code}${text}${ANSI.reset}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function info(text) {
|
|
59
|
+
return paint(text, ANSI.cyan);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function success(text) {
|
|
63
|
+
return paint(text, ANSI.green);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function warn(text) {
|
|
67
|
+
return paint(text, ANSI.yellow);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function danger(text) {
|
|
71
|
+
return paint(text, ANSI.red);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function dim(text) {
|
|
75
|
+
return paint(text, ANSI.dim);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function bold(text) {
|
|
79
|
+
return paint(text, ANSI.bold);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function parseArgs(argv) {
|
|
83
|
+
const flags = {};
|
|
84
|
+
let command = null;
|
|
85
|
+
|
|
86
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
87
|
+
const token = argv[i];
|
|
88
|
+
|
|
89
|
+
if (token === "-y") {
|
|
90
|
+
flags["--yes"] = true;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (token.startsWith("--")) {
|
|
95
|
+
const next = argv[i + 1];
|
|
96
|
+
const boolFlags = new Set(["--help", "--version", "--yes", "--verbose", "--dry-run", "--activate"]);
|
|
97
|
+
if (!boolFlags.has(token) && next && !next.startsWith("--")) {
|
|
98
|
+
flags[token] = next;
|
|
99
|
+
i += 1;
|
|
100
|
+
} else {
|
|
101
|
+
flags[token] = true;
|
|
102
|
+
}
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!command) {
|
|
107
|
+
command = token;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!flags._) flags._ = [];
|
|
112
|
+
flags._.push(token);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { command: command || "init", flags };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function banner() {
|
|
119
|
+
console.log(bold(info("Clawtamer CLI")));
|
|
120
|
+
console.log(dim("Tame the lobster."));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function logStep(flags, message) {
|
|
124
|
+
if (!flags["--verbose"]) return;
|
|
125
|
+
console.log(dim(`[debug] ${message}`));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getApiBase(flags, config) {
|
|
129
|
+
if (flags["--api-base"]) return String(flags["--api-base"]);
|
|
130
|
+
const local = config?.models?.providers?.clawtamer?.baseUrl;
|
|
131
|
+
if (local) return local;
|
|
132
|
+
return API_BASE;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function findOpenClawConfig(baseDir) {
|
|
136
|
+
const candidates = getConfigSearchPaths(baseDir);
|
|
137
|
+
|
|
138
|
+
for (const candidate of candidates) {
|
|
139
|
+
try {
|
|
140
|
+
const raw = fs.readFileSync(candidate, "utf8");
|
|
141
|
+
return { configPath: candidate, config: JSON.parse(raw) };
|
|
142
|
+
} catch {
|
|
143
|
+
// continue
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function getConfigSearchPaths(baseDir) {
|
|
151
|
+
const paths = [];
|
|
152
|
+
const activeConfigPath = detectActiveConfigPath(baseDir);
|
|
153
|
+
if (activeConfigPath) paths.push(activeConfigPath);
|
|
154
|
+
if (process.env.OPENCLAW_CONFIG_PATH) paths.push(path.resolve(String(process.env.OPENCLAW_CONFIG_PATH)));
|
|
155
|
+
if (process.env.OPENCLAW_HOME) paths.push(path.join(process.env.OPENCLAW_HOME, "openclaw.json"));
|
|
156
|
+
paths.push(path.join(baseDir, OPENCLAW_DIR_NAME, "openclaw.json"));
|
|
157
|
+
paths.push(path.join(baseDir, "..", OPENCLAW_DIR_NAME, "openclaw.json"));
|
|
158
|
+
paths.push(path.join(baseDir, "openclaw.json"));
|
|
159
|
+
paths.push(path.join(baseDir, "..", "openclaw.json"));
|
|
160
|
+
paths.push(path.join(os.homedir(), OPENCLAW_DIR_NAME, "openclaw.json"));
|
|
161
|
+
return unique(paths.map((p) => path.resolve(p)));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function detectActiveConfigPath(baseDir) {
|
|
165
|
+
try {
|
|
166
|
+
const out = execFileSync("openclaw", ["config", "file"], {
|
|
167
|
+
cwd: baseDir,
|
|
168
|
+
encoding: "utf8",
|
|
169
|
+
stdio: ["ignore", "pipe", "ignore"]
|
|
170
|
+
}).trim();
|
|
171
|
+
const firstLine = out.split(/\r?\n/).map((l) => l.trim()).find(Boolean);
|
|
172
|
+
if (!firstLine) return null;
|
|
173
|
+
const rawPath = firstLine.replace(/^Config file:\s*/i, "");
|
|
174
|
+
if (rawPath.startsWith("~/")) return path.join(os.homedir(), rawPath.slice(2));
|
|
175
|
+
return rawPath;
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getStorageDir(configDir) {
|
|
182
|
+
return path.join(configDir, "clawtamer");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function loadMetadata(configDir) {
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(fs.readFileSync(path.join(getStorageDir(configDir), "metadata.json"), "utf8"));
|
|
188
|
+
} catch {
|
|
189
|
+
return {};
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function saveMetadata(configDir, data) {
|
|
194
|
+
const dir = getStorageDir(configDir);
|
|
195
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
196
|
+
const existing = loadMetadata(configDir);
|
|
197
|
+
fs.writeFileSync(path.join(dir, "metadata.json"), JSON.stringify({ ...existing, ...data }, null, 2) + "\n");
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clearMetadata(configDir) {
|
|
201
|
+
try {
|
|
202
|
+
fs.unlinkSync(path.join(getStorageDir(configDir), "metadata.json"));
|
|
203
|
+
} catch {}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function promptText(question, { secret = false, required = true } = {}) {
|
|
207
|
+
if (!stdin.isTTY || !stdout.isTTY) {
|
|
208
|
+
throw new Error(`Interactive prompt unavailable for "${question.trim()}". Use CLI flags instead.`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
212
|
+
try {
|
|
213
|
+
if (!secret) {
|
|
214
|
+
const ans = (await rl.question(question)).trim();
|
|
215
|
+
if (required && !ans) return promptText(question, { secret, required });
|
|
216
|
+
return ans;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const oldWrite = stdout.write;
|
|
220
|
+
oldWrite.call(stdout, `${question} (input hidden): `);
|
|
221
|
+
stdout.write = function mutedWrite(chunk, encoding, cb) {
|
|
222
|
+
if (typeof chunk === "string" && chunk.includes("\n")) {
|
|
223
|
+
return oldWrite.call(stdout, chunk, encoding, cb);
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const ans = (await rl.question("")).trim();
|
|
229
|
+
stdout.write = oldWrite;
|
|
230
|
+
oldWrite.call(stdout, "\n");
|
|
231
|
+
|
|
232
|
+
if (required && !ans) {
|
|
233
|
+
oldWrite.call(stdout, "No input captured. Retrying with visible input for reliability.\n");
|
|
234
|
+
return promptText(question, { secret: false, required });
|
|
235
|
+
}
|
|
236
|
+
return ans;
|
|
237
|
+
} finally {
|
|
238
|
+
rl.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function confirm(question, fallbackNo = true) {
|
|
243
|
+
const answer = (await promptText(`${question} `, { required: false })).toLowerCase();
|
|
244
|
+
if (!answer) return !fallbackNo;
|
|
245
|
+
return answer === "y" || answer === "yes";
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function canPromptInteractively() {
|
|
249
|
+
return Boolean(stdin.isTTY && stdout.isTTY);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function mask(value) {
|
|
253
|
+
if (!value || value.length < 8) return "***";
|
|
254
|
+
return `${value.slice(0, 6)}...${value.slice(-4)}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function httpJson(url, options = {}) {
|
|
258
|
+
const timeoutMs = Number(options.timeoutMs || 30_000);
|
|
259
|
+
const controller = new AbortController();
|
|
260
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const response = await fetch(url, { ...options, signal: controller.signal });
|
|
264
|
+
const text = await response.text();
|
|
265
|
+
let data;
|
|
266
|
+
try {
|
|
267
|
+
data = text ? JSON.parse(text) : {};
|
|
268
|
+
} catch {
|
|
269
|
+
data = { raw: text };
|
|
270
|
+
}
|
|
271
|
+
return { ok: response.ok, status: response.status, data, headers: response.headers };
|
|
272
|
+
} catch (err) {
|
|
273
|
+
if (err.name === "AbortError") {
|
|
274
|
+
throw new Error(`Request timeout after ${timeoutMs}ms for ${url}`);
|
|
275
|
+
}
|
|
276
|
+
throw err;
|
|
277
|
+
} finally {
|
|
278
|
+
clearTimeout(timeout);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function ensureAnthropicKey(key) {
|
|
283
|
+
const raw = String(key || "").trim();
|
|
284
|
+
if (!raw.startsWith("sk-ant-")) {
|
|
285
|
+
throw new Error("Anthropic key must start with sk-ant-");
|
|
286
|
+
}
|
|
287
|
+
if (/\s/.test(raw)) {
|
|
288
|
+
throw new Error("Anthropic key cannot contain spaces or newlines");
|
|
289
|
+
}
|
|
290
|
+
if ((raw.match(/sk-ant-/g) || []).length > 1) {
|
|
291
|
+
throw new Error("Anthropic key appears pasted multiple times. Paste one key only.");
|
|
292
|
+
}
|
|
293
|
+
if (raw.length < 24 || raw.length > 500) {
|
|
294
|
+
throw new Error("Anthropic key length looks invalid. Paste a full key from console.anthropic.com.");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function ensureProxyKey(key) {
|
|
299
|
+
const raw = String(key || "").trim();
|
|
300
|
+
if (!raw.startsWith("sk-cltm-")) {
|
|
301
|
+
throw new Error("Clawtamer API key must start with sk-cltm-");
|
|
302
|
+
}
|
|
303
|
+
if (/\s/.test(raw)) {
|
|
304
|
+
throw new Error("Clawtamer API key cannot contain spaces or newlines");
|
|
305
|
+
}
|
|
306
|
+
if ((raw.match(/sk-cltm-/g) || []).length > 1) {
|
|
307
|
+
throw new Error("Clawtamer API key appears pasted multiple times. Paste one key only.");
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function ensureEmail(value) {
|
|
312
|
+
const email = String(value || "").trim().toLowerCase();
|
|
313
|
+
if (!email) {
|
|
314
|
+
throw new Error("Email is required");
|
|
315
|
+
}
|
|
316
|
+
const rx = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
317
|
+
if (!rx.test(email)) {
|
|
318
|
+
throw new Error("Enter a valid email address (example: you@example.com)");
|
|
319
|
+
}
|
|
320
|
+
if (email.length > 254) {
|
|
321
|
+
throw new Error("Email is too long");
|
|
322
|
+
}
|
|
323
|
+
return email;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function promptValidated(question, validate) {
|
|
327
|
+
while (true) {
|
|
328
|
+
const answer = await promptText(question);
|
|
329
|
+
const err = validate(answer);
|
|
330
|
+
if (!err) return answer;
|
|
331
|
+
console.warn(err);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function promptAnthropicKey(question) {
|
|
336
|
+
return promptValidated(question, (value) => {
|
|
337
|
+
try {
|
|
338
|
+
ensureAnthropicKey(value);
|
|
339
|
+
return null;
|
|
340
|
+
} catch (err) {
|
|
341
|
+
return err.message || String(err);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function promptProxyKey(question) {
|
|
347
|
+
return promptValidated(question, (value) => {
|
|
348
|
+
try {
|
|
349
|
+
ensureProxyKey(value);
|
|
350
|
+
return null;
|
|
351
|
+
} catch (err) {
|
|
352
|
+
return err.message || String(err);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
async function promptAccountMode() {
|
|
358
|
+
const answer = (
|
|
359
|
+
await promptText("Did you sign up at https://www.clawtamer.ai/dashboard? (y/N): ", { required: false })
|
|
360
|
+
)
|
|
361
|
+
.trim()
|
|
362
|
+
.toLowerCase();
|
|
363
|
+
if (answer === "y" || answer === "yes") return "existing";
|
|
364
|
+
return "new";
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function ensureObjectPath(target, keys) {
|
|
368
|
+
let ref = target;
|
|
369
|
+
for (const key of keys) {
|
|
370
|
+
if (!ref[key] || typeof ref[key] !== "object") ref[key] = {};
|
|
371
|
+
ref = ref[key];
|
|
372
|
+
}
|
|
373
|
+
return ref;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function upsertClawtamerProvider(config, apiBase, proxyKey) {
|
|
377
|
+
ensureObjectPath(config, ["models", "providers"]);
|
|
378
|
+
const modelEntry = (id, name, input) => ({
|
|
379
|
+
id,
|
|
380
|
+
name,
|
|
381
|
+
contextWindow: 200000,
|
|
382
|
+
maxTokens: 8192,
|
|
383
|
+
reasoning: false,
|
|
384
|
+
input
|
|
385
|
+
});
|
|
386
|
+
config.models.providers.clawtamer = {
|
|
387
|
+
baseUrl: apiBase,
|
|
388
|
+
apiKey: proxyKey,
|
|
389
|
+
api: "anthropic-messages",
|
|
390
|
+
models: [
|
|
391
|
+
modelEntry("auto", "Auto (Clawtamer optimizer)", ["text"]),
|
|
392
|
+
modelEntry("claude-haiku-4-5", "Claude Haiku 4.5", ["text"]),
|
|
393
|
+
modelEntry("claude-sonnet-4-5", "Claude Sonnet 4.5", ["text", "image"]),
|
|
394
|
+
modelEntry("claude-opus-4-6", "Claude Opus 4.6", ["text", "image"])
|
|
395
|
+
]
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function ensureClawtamerAllowlist(config) {
|
|
400
|
+
const allow = ensureObjectPath(config, ["agents", "defaults", "models"]);
|
|
401
|
+
if (!allow["clawtamer/auto"]) allow["clawtamer/auto"] = { alias: "Clawtamer Auto" };
|
|
402
|
+
if (!allow["clawtamer/claude-haiku-4-5"]) allow["clawtamer/claude-haiku-4-5"] = { alias: "Clawtamer Haiku 4.5" };
|
|
403
|
+
if (!allow["clawtamer/claude-sonnet-4-5"]) allow["clawtamer/claude-sonnet-4-5"] = { alias: "Clawtamer Sonnet 4.5" };
|
|
404
|
+
if (!allow["clawtamer/claude-opus-4-6"]) allow["clawtamer/claude-opus-4-6"] = { alias: "Clawtamer Opus 4.6" };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function collectAgentOverrideWarnings(configDir) {
|
|
408
|
+
const warnings = [];
|
|
409
|
+
const agentsRoot = path.join(configDir, "agents");
|
|
410
|
+
if (!fs.existsSync(agentsRoot)) return warnings;
|
|
411
|
+
|
|
412
|
+
const entries = fs.readdirSync(agentsRoot, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
413
|
+
for (const entry of entries) {
|
|
414
|
+
const agentName = entry.name;
|
|
415
|
+
const candidate = path.join(agentsRoot, agentName, "agent", "openclaw.json");
|
|
416
|
+
if (!fs.existsSync(candidate)) continue;
|
|
417
|
+
|
|
418
|
+
try {
|
|
419
|
+
const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
|
|
420
|
+
const directPrimary = parsed?.model?.primary;
|
|
421
|
+
const defaultsPrimary = parsed?.agents?.defaults?.model?.primary;
|
|
422
|
+
const chosen = directPrimary || defaultsPrimary || null;
|
|
423
|
+
if (chosen && !String(chosen).startsWith("clawtamer/")) {
|
|
424
|
+
warnings.push({
|
|
425
|
+
agent: agentName,
|
|
426
|
+
model: String(chosen),
|
|
427
|
+
file: candidate
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
} catch {
|
|
431
|
+
// ignore invalid agent config files
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
return warnings;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function writeConfig(configPath, config) {
|
|
439
|
+
const dir = path.dirname(configPath);
|
|
440
|
+
const tmp = path.join(dir, `.openclaw.json.clawtamer.tmp-${Date.now()}`);
|
|
441
|
+
fs.writeFileSync(tmp, JSON.stringify(config, null, 2) + "\n", "utf8");
|
|
442
|
+
fs.renameSync(tmp, configPath);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function cloneJson(value) {
|
|
446
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function unique(values) {
|
|
450
|
+
return [...new Set(values.filter(Boolean))];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function ensureBackupFile(configPath) {
|
|
454
|
+
const backupPath = `${configPath}.bak.clawtamer`;
|
|
455
|
+
if (!fs.existsSync(backupPath)) {
|
|
456
|
+
fs.writeFileSync(backupPath, fs.readFileSync(configPath, "utf8"), "utf8");
|
|
457
|
+
}
|
|
458
|
+
return backupPath;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function buildFallbacks(previousModelConfig) {
|
|
462
|
+
const previousPrimary = previousModelConfig?.primary;
|
|
463
|
+
const previousFallbacks = Array.isArray(previousModelConfig?.fallbacks) ? previousModelConfig.fallbacks : [];
|
|
464
|
+
const safePrevious = [previousPrimary, ...previousFallbacks].filter((m) => !String(m || "").startsWith("clawtamer/"));
|
|
465
|
+
const merged = unique(safePrevious);
|
|
466
|
+
if (merged.length > 0) return merged;
|
|
467
|
+
return ["anthropic/claude-sonnet-4-5"];
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function detectBaselineModel(modelConfig) {
|
|
471
|
+
const primary = modelConfig?.primary;
|
|
472
|
+
const fallbacks = Array.isArray(modelConfig?.fallbacks) ? modelConfig.fallbacks : [];
|
|
473
|
+
const candidates = [primary, ...fallbacks].filter(Boolean).map((m) => String(m));
|
|
474
|
+
const firstNonClawtamer = candidates.find((m) => !m.startsWith("clawtamer/"));
|
|
475
|
+
return firstNonClawtamer || "anthropic/claude-opus-4-6";
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
async function preflightProxy(apiBase, proxyKey, flags) {
|
|
479
|
+
logStep(flags, "Running proxy readiness check...");
|
|
480
|
+
const payload = {
|
|
481
|
+
model: "claude-sonnet-4-5",
|
|
482
|
+
max_tokens: 16,
|
|
483
|
+
messages: [{ role: "user", content: "Reply with: ok" }]
|
|
484
|
+
};
|
|
485
|
+
|
|
486
|
+
const result = await httpJson(`${apiBase}/v1/messages`, {
|
|
487
|
+
method: "POST",
|
|
488
|
+
headers: { "content-type": "application/json", "x-api-key": proxyKey },
|
|
489
|
+
body: JSON.stringify(payload),
|
|
490
|
+
timeoutMs: 45_000
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
if (!result.ok) {
|
|
494
|
+
const reason = result.data?.error?.message || JSON.stringify(result.data);
|
|
495
|
+
return { ok: false, reason: reason || `HTTP ${result.status}` };
|
|
496
|
+
}
|
|
497
|
+
return { ok: true };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function runPreflightWithRetry(apiBase, proxyKey, flags, attempts = 2) {
|
|
501
|
+
let last = { ok: false, reason: "not-run" };
|
|
502
|
+
for (let i = 1; i <= attempts; i += 1) {
|
|
503
|
+
try {
|
|
504
|
+
last = await preflightProxy(apiBase, proxyKey, flags);
|
|
505
|
+
} catch (err) {
|
|
506
|
+
last = { ok: false, reason: err.message || String(err) };
|
|
507
|
+
}
|
|
508
|
+
if (last.ok) return last;
|
|
509
|
+
logStep(flags, `Preflight attempt ${i}/${attempts} failed: ${last.reason}`);
|
|
510
|
+
if (i < attempts) {
|
|
511
|
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return last;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
async function runInstallValidation(apiBase, proxyKey) {
|
|
518
|
+
const payload = {
|
|
519
|
+
model: "claude-sonnet-4-5",
|
|
520
|
+
max_tokens: 24,
|
|
521
|
+
messages: [{ role: "user", content: "Reply with the word: healthy" }]
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
const r = await httpJson(`${apiBase}/v1/messages`, {
|
|
525
|
+
method: "POST",
|
|
526
|
+
headers: { "content-type": "application/json", "x-api-key": proxyKey },
|
|
527
|
+
body: JSON.stringify(payload),
|
|
528
|
+
timeoutMs: 45_000
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
if (!r.ok) {
|
|
532
|
+
const reason = r.data?.error?.message || JSON.stringify(r.data);
|
|
533
|
+
return { ok: false, reason: reason || `HTTP ${r.status}` };
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
ok: true,
|
|
538
|
+
routedModel: r.headers.get("x-clawtamer-routed-model") || "n/a",
|
|
539
|
+
savedUsd: r.headers.get("x-clawtamer-saved-usd") || "n/a",
|
|
540
|
+
requestId: r.headers.get("x-clawtamer-request-id") || "n/a"
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function signupWithRetry(apiBase, payload, flags, attempts = 2) {
|
|
545
|
+
let last = null;
|
|
546
|
+
for (let i = 1; i <= attempts; i += 1) {
|
|
547
|
+
last = await httpJson(`${apiBase}/v1/signup`, {
|
|
548
|
+
method: "POST",
|
|
549
|
+
headers: { "content-type": "application/json" },
|
|
550
|
+
body: JSON.stringify(payload),
|
|
551
|
+
timeoutMs: 30_000
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
if (last.ok && last.data?.api_key) return last;
|
|
555
|
+
const status = Number(last.status || 0);
|
|
556
|
+
const retryable = status >= 500 || status === 429 || status === 0;
|
|
557
|
+
if (!retryable || i >= attempts) break;
|
|
558
|
+
logStep(flags, `Signup attempt ${i}/${attempts} failed with HTTP ${status}; retrying...`);
|
|
559
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
560
|
+
}
|
|
561
|
+
return last;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function storeUpstreamKey(apiBase, proxyKey, upstreamKey) {
|
|
565
|
+
return httpJson(`${apiBase}/v1/store-key`, {
|
|
566
|
+
method: "POST",
|
|
567
|
+
headers: { "content-type": "application/json", "x-api-key": proxyKey },
|
|
568
|
+
body: JSON.stringify({ upstream_key: upstreamKey }),
|
|
569
|
+
timeoutMs: 30_000
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function cmdInit(flags) {
|
|
574
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
575
|
+
logStep(flags, `Searching OpenClaw config under: ${baseDir}`);
|
|
576
|
+
const found = findOpenClawConfig(baseDir);
|
|
577
|
+
if (!found) {
|
|
578
|
+
const searchPaths = getConfigSearchPaths(baseDir);
|
|
579
|
+
console.error("No OpenClaw config found.");
|
|
580
|
+
console.error("");
|
|
581
|
+
console.error("Clawtamer patches an existing OpenClaw config. Run from an OpenClaw project, or use --dir <path>.");
|
|
582
|
+
console.error("");
|
|
583
|
+
console.error("Paths checked:");
|
|
584
|
+
searchPaths.forEach((p) => console.error(` - ${p}`));
|
|
585
|
+
console.error("");
|
|
586
|
+
console.error("If you don't have OpenClaw yet, install it first. Then run `clawtamer init` from that project.");
|
|
587
|
+
process.exit(1);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
const { configPath, config } = found;
|
|
591
|
+
const configDir = path.dirname(configPath);
|
|
592
|
+
const apiBase = getApiBase(flags, config);
|
|
593
|
+
logStep(flags, `Using config: ${configPath}`);
|
|
594
|
+
logStep(flags, `Using API base: ${apiBase}`);
|
|
595
|
+
|
|
596
|
+
const existing = config.models?.providers?.clawtamer;
|
|
597
|
+
if (existing && !flags["--yes"]) {
|
|
598
|
+
const proceed = await confirm("Clawtamer is already configured. Reapply setup? (y/N)", true);
|
|
599
|
+
if (!proceed) {
|
|
600
|
+
console.log("No changes applied.");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const modeFromFlags = flags["--new-user"] ? "new" : flags["--existing-user"] ? "existing" : null;
|
|
606
|
+
const accountMode = modeFromFlags || (canPromptInteractively() ? await promptAccountMode() : "new");
|
|
607
|
+
const emailPrompt =
|
|
608
|
+
accountMode === "existing"
|
|
609
|
+
? "Please enter your account email (please use the same email you used to signup): "
|
|
610
|
+
: "Please enter your account email (you will use this to access your dashboard): ";
|
|
611
|
+
const emailInput = flags["--email"] || (await promptValidated(emailPrompt, (value) => {
|
|
612
|
+
try {
|
|
613
|
+
ensureEmail(value);
|
|
614
|
+
return null;
|
|
615
|
+
} catch (err) {
|
|
616
|
+
return err.message || String(err);
|
|
617
|
+
}
|
|
618
|
+
}));
|
|
619
|
+
const email = ensureEmail(emailInput);
|
|
620
|
+
const explicitProxyKey = flags["--proxy-key"] || (String(flags["--key"] || "").startsWith("sk-cltm-") ? flags["--key"] : null);
|
|
621
|
+
const upstreamKey = flags["--upstream-key"] || (String(flags["--key"] || "").startsWith("sk-ant-") ? flags["--key"] : null);
|
|
622
|
+
if (upstreamKey) ensureAnthropicKey(upstreamKey);
|
|
623
|
+
if (explicitProxyKey) ensureProxyKey(explicitProxyKey);
|
|
624
|
+
|
|
625
|
+
let resolvedUpstreamKey = upstreamKey;
|
|
626
|
+
let resolvedProxyKey = explicitProxyKey;
|
|
627
|
+
if (!resolvedProxyKey && accountMode === "existing" && canPromptInteractively()) {
|
|
628
|
+
resolvedProxyKey = await promptProxyKey(
|
|
629
|
+
"Please enter your existing Clawtamer API key (sk-cltm-...). Don't know it? Log in at https://www.clawtamer.ai/login then open Dashboard and click 'Reveal API Key': "
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
if (!resolvedUpstreamKey && canPromptInteractively()) {
|
|
633
|
+
resolvedUpstreamKey = await promptAnthropicKey(
|
|
634
|
+
"Please enter your Anthropic API key (get it from https://console.anthropic.com/settings/keys): "
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
if (resolvedUpstreamKey) {
|
|
638
|
+
ensureAnthropicKey(resolvedUpstreamKey);
|
|
639
|
+
console.log(success(`Anthropic key successfully detected: ${mask(resolvedUpstreamKey)}`));
|
|
640
|
+
}
|
|
641
|
+
if (!resolvedUpstreamKey && !resolvedProxyKey) {
|
|
642
|
+
console.error("Missing credentials. Provide --upstream-key sk-ant-... or --proxy-key sk-cltm-..., or run interactively.");
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
const detectedBaselineModel = detectBaselineModel(config.agents?.defaults?.model || {});
|
|
646
|
+
|
|
647
|
+
let proxyKey = resolvedProxyKey || null;
|
|
648
|
+
if (!proxyKey) {
|
|
649
|
+
logStep(flags, "Creating your Clawtamer account...");
|
|
650
|
+
const signup = await signupWithRetry(apiBase, {
|
|
651
|
+
email,
|
|
652
|
+
upstream_key: resolvedUpstreamKey,
|
|
653
|
+
provider: "anthropic",
|
|
654
|
+
framework: "openclaw",
|
|
655
|
+
baseline_model: detectedBaselineModel
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
if (!signup.ok || !signup.data?.api_key) {
|
|
659
|
+
const code = String(signup.data?.error?.code || signup.data?.code || "").toUpperCase();
|
|
660
|
+
if (code.includes("EMAIL_EXISTS") || code.includes("ACCOUNT_EXISTS")) {
|
|
661
|
+
if (canPromptInteractively()) {
|
|
662
|
+
console.warn(warn("This email already exists. Switching to existing-user flow."));
|
|
663
|
+
proxyKey = await promptProxyKey(
|
|
664
|
+
"Please enter your existing Clawtamer API key (sk-cltm-...). Don't know it? Log in at https://www.clawtamer.ai/login then open Dashboard and click 'Reveal API Key': "
|
|
665
|
+
);
|
|
666
|
+
logStep(flags, "Existing account key captured.");
|
|
667
|
+
} else {
|
|
668
|
+
console.error("This email already exists. Re-run with your existing proxy key:");
|
|
669
|
+
console.error(" npx clawtamer init --proxy-key sk-cltm-... --email <email>");
|
|
670
|
+
process.exit(1);
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
console.error("Signup failed:", signup.data?.error || signup.data);
|
|
674
|
+
if (Number(signup.status || 0) >= 500) {
|
|
675
|
+
console.error("Server error during signup (e.g. bad gateway). Please retry in a moment.");
|
|
676
|
+
}
|
|
677
|
+
process.exit(1);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (!proxyKey) {
|
|
681
|
+
proxyKey = signup.data.api_key;
|
|
682
|
+
logStep(flags, "Signup complete.");
|
|
683
|
+
}
|
|
684
|
+
} else {
|
|
685
|
+
logStep(flags, "Using provided proxy key (signup skipped).");
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
const dryRun = Boolean(flags["--dry-run"]);
|
|
689
|
+
|
|
690
|
+
if (resolvedUpstreamKey && !dryRun) {
|
|
691
|
+
const store = await storeUpstreamKey(apiBase, proxyKey, resolvedUpstreamKey);
|
|
692
|
+
if (!store.ok) {
|
|
693
|
+
console.error("Failed to store upstream key:", store.data?.error || store.data);
|
|
694
|
+
process.exit(1);
|
|
695
|
+
}
|
|
696
|
+
logStep(flags, "Upstream key linked.");
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
let preflight = await runPreflightWithRetry(apiBase, proxyKey, flags, 2);
|
|
700
|
+
|
|
701
|
+
if (!preflight.ok && !dryRun && canPromptInteractively()) {
|
|
702
|
+
console.warn(warn(`Preflight failed: ${preflight.reason}`));
|
|
703
|
+
console.warn(warn("Let's retry by relinking your Anthropic key."));
|
|
704
|
+
console.warn(warn("You can find it at: https://console.anthropic.com/settings/keys"));
|
|
705
|
+
|
|
706
|
+
for (let i = 1; i <= 2; i += 1) {
|
|
707
|
+
const retryKey = await promptAnthropicKey(`Anthropic API key retry (${i}/2): `);
|
|
708
|
+
console.log(success(`Anthropic key successfully detected: ${mask(retryKey)}`));
|
|
709
|
+
const storeRetry = await storeUpstreamKey(apiBase, proxyKey, retryKey);
|
|
710
|
+
if (!storeRetry.ok) {
|
|
711
|
+
console.warn(warn(`Could not store retry key (${i}/2): ${JSON.stringify(storeRetry.data?.error || storeRetry.data)}`));
|
|
712
|
+
continue;
|
|
713
|
+
}
|
|
714
|
+
preflight = await runPreflightWithRetry(apiBase, proxyKey, flags, 2);
|
|
715
|
+
if (preflight.ok) {
|
|
716
|
+
resolvedUpstreamKey = retryKey;
|
|
717
|
+
logStep(flags, "Preflight passed after Anthropic key retry.");
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const originalModelConfig = cloneJson(config.agents?.defaults?.model || {});
|
|
724
|
+
const originalDefaultsModels = cloneJson(config.agents?.defaults?.models || null);
|
|
725
|
+
const originalModel = originalModelConfig?.primary || null;
|
|
726
|
+
const shouldActivate = Boolean(preflight.ok || flags["--activate"]);
|
|
727
|
+
|
|
728
|
+
if (!preflight.ok) {
|
|
729
|
+
console.warn(warn(`Preflight failed: ${preflight.reason}`));
|
|
730
|
+
if (!flags["--activate"]) {
|
|
731
|
+
console.warn(warn("Safety mode: provider will be added, but primary model will NOT be switched."));
|
|
732
|
+
console.warn(warn("Use --activate to force switch (not recommended)."));
|
|
733
|
+
} else {
|
|
734
|
+
console.warn(warn("Forced activation enabled despite failed preflight."));
|
|
735
|
+
}
|
|
736
|
+
} else {
|
|
737
|
+
logStep(flags, "Preflight passed.");
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
if (dryRun) {
|
|
741
|
+
console.log("Dry run complete. No OpenClaw config changes were written.");
|
|
742
|
+
console.log(`Proxy key: ${mask(proxyKey)}`);
|
|
743
|
+
return;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
logStep(flags, "Applying OpenClaw config patch...");
|
|
747
|
+
ensureBackupFile(configPath);
|
|
748
|
+
|
|
749
|
+
upsertClawtamerProvider(config, apiBase, proxyKey);
|
|
750
|
+
ensureClawtamerAllowlist(config);
|
|
751
|
+
|
|
752
|
+
ensureObjectPath(config, ["agents", "defaults", "model"]);
|
|
753
|
+
|
|
754
|
+
if (shouldActivate) {
|
|
755
|
+
config.agents.defaults.model.primary = "clawtamer/auto";
|
|
756
|
+
config.agents.defaults.model.fallbacks = buildFallbacks(originalModelConfig);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
writeConfig(configPath, config);
|
|
760
|
+
logStep(flags, "Config patch saved.");
|
|
761
|
+
saveMetadata(configDir, {
|
|
762
|
+
originalModelConfig,
|
|
763
|
+
originalDefaultsModels,
|
|
764
|
+
originalModel,
|
|
765
|
+
activated: shouldActivate,
|
|
766
|
+
setupTimestamp: new Date().toISOString(),
|
|
767
|
+
apiBase,
|
|
768
|
+
configPath,
|
|
769
|
+
email,
|
|
770
|
+
apiKey: proxyKey
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
if (shouldActivate) {
|
|
774
|
+
const postActivation = await runPreflightWithRetry(apiBase, proxyKey, flags, 2);
|
|
775
|
+
if (!postActivation.ok) {
|
|
776
|
+
console.warn(`Post-activation health check failed: ${postActivation.reason}`);
|
|
777
|
+
console.warn("Safety rollback: keeping provider installed but reverting default model to previous settings.");
|
|
778
|
+
const rollbackConfig = cloneJson(config);
|
|
779
|
+
rollbackConfig.agents.defaults.model = originalModelConfig || {};
|
|
780
|
+
writeConfig(configPath, rollbackConfig);
|
|
781
|
+
saveMetadata(configDir, {
|
|
782
|
+
activated: false,
|
|
783
|
+
activationAutoDisabled: true,
|
|
784
|
+
activationAutoDisabledReason: postActivation.reason
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
if (!flags["--skip-install-test"]) {
|
|
790
|
+
logStep(flags, "Running automatic post-install proxy test...");
|
|
791
|
+
const installValidation = await runInstallValidation(apiBase, proxyKey);
|
|
792
|
+
if (!installValidation.ok) {
|
|
793
|
+
console.warn(`Post-install test failed: ${installValidation.reason}`);
|
|
794
|
+
console.warn("Safety mode: keeping provider installed but leaving model staged.");
|
|
795
|
+
const rollbackConfig = cloneJson(config);
|
|
796
|
+
rollbackConfig.agents.defaults.model = originalModelConfig || {};
|
|
797
|
+
writeConfig(configPath, rollbackConfig);
|
|
798
|
+
saveMetadata(configDir, {
|
|
799
|
+
activated: false,
|
|
800
|
+
activationAutoDisabled: true,
|
|
801
|
+
activationAutoDisabledReason: installValidation.reason,
|
|
802
|
+
installTestFailed: true
|
|
803
|
+
});
|
|
804
|
+
console.warn("Suggested next steps:");
|
|
805
|
+
console.warn("1) Verify your Anthropic key at https://console.anthropic.com/settings/keys");
|
|
806
|
+
console.warn("2) Run: npx clawtamer store-key --dir " + baseDir);
|
|
807
|
+
console.warn("3) Run: npx clawtamer test --dir " + baseDir);
|
|
808
|
+
} else {
|
|
809
|
+
console.log("");
|
|
810
|
+
console.log(bold(info("Post-install verification:")));
|
|
811
|
+
if (flags["--verbose"]) {
|
|
812
|
+
console.log(`- routed model: ${installValidation.routedModel}`);
|
|
813
|
+
console.log(`- saved usd: ${installValidation.savedUsd}`);
|
|
814
|
+
console.log(`- request id: ${installValidation.requestId}`);
|
|
815
|
+
console.log("");
|
|
816
|
+
}
|
|
817
|
+
console.log(bold(success("All tests completed SUCCESSFULLY.")));
|
|
818
|
+
console.log("");
|
|
819
|
+
console.log("Clawtamer is installed and now routing your requests.");
|
|
820
|
+
console.log("Clawtamer will automatically route requests and reduce API cost.");
|
|
821
|
+
console.log("");
|
|
822
|
+
console.log("Track savings anytime:");
|
|
823
|
+
console.log("- npx clawtamer savings");
|
|
824
|
+
console.log("- Dashboard: https://www.clawtamer.ai/dashboard");
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const finalMeta = loadMetadata(configDir);
|
|
829
|
+
const finalActivated = Boolean(finalMeta.activated);
|
|
830
|
+
|
|
831
|
+
console.log("");
|
|
832
|
+
console.log(bold(success("Clawtamer setup COMPLETED.")));
|
|
833
|
+
if (flags["--verbose"]) {
|
|
834
|
+
console.log(`Config: ${configPath}`);
|
|
835
|
+
console.log(`Proxy key: ${mask(proxyKey)}`);
|
|
836
|
+
if (finalActivated) {
|
|
837
|
+
console.log("Mode: active (primary=clawtamer/auto with preserved fallbacks)");
|
|
838
|
+
} else {
|
|
839
|
+
console.log("Mode: staged (provider installed only, primary unchanged)");
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
console.log("");
|
|
843
|
+
console.log(bold(warn("PLEASE RESTART YOUR GATEWAY (if already running):")));
|
|
844
|
+
console.log("1. Stop current gateway process (Ctrl+C where it is running).");
|
|
845
|
+
console.log("2. Start again: openclaw gateway");
|
|
846
|
+
console.log("3. Then open a new chat session and verify model/provider.");
|
|
847
|
+
const overrideWarnings = collectAgentOverrideWarnings(configDir);
|
|
848
|
+
if (overrideWarnings.length > 0) {
|
|
849
|
+
console.warn(warn("Warning: one or more agent-level model overrides may bypass clawtamer/auto:"));
|
|
850
|
+
overrideWarnings.forEach((w) => {
|
|
851
|
+
console.warn(`- agent=${w.agent} model=${w.model}`);
|
|
852
|
+
logStep(flags, ` file: ${w.file}`);
|
|
853
|
+
});
|
|
854
|
+
console.warn("Update those agent configs to clawtamer/auto (or remove agent-level primary) to enforce proxy routing.");
|
|
855
|
+
}
|
|
856
|
+
console.log("");
|
|
857
|
+
console.log(success("Enjoy your savings with Clawtamer!"));
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
async function resolveProxyKey(flags, configDir, config) {
|
|
861
|
+
if (flags["--proxy-key"]) return String(flags["--proxy-key"]);
|
|
862
|
+
if (flags["--key"]) return String(flags["--key"]);
|
|
863
|
+
const fromConfig = config.models?.providers?.clawtamer?.apiKey;
|
|
864
|
+
if (fromConfig) return fromConfig;
|
|
865
|
+
const meta = loadMetadata(configDir);
|
|
866
|
+
if (meta?.apiKey) return meta.apiKey;
|
|
867
|
+
return null;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
async function cmdStatus(flags) {
|
|
871
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
872
|
+
const found = findOpenClawConfig(baseDir);
|
|
873
|
+
if (!found) {
|
|
874
|
+
console.error("No OpenClaw config found.");
|
|
875
|
+
process.exit(1);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
const { configPath, config } = found;
|
|
879
|
+
const configDir = path.dirname(configPath);
|
|
880
|
+
const apiBase = getApiBase(flags, config);
|
|
881
|
+
logStep(flags, `Status check using API base: ${apiBase}`);
|
|
882
|
+
const provider = config.models?.providers?.clawtamer;
|
|
883
|
+
|
|
884
|
+
if (!provider) {
|
|
885
|
+
console.log("Clawtamer is not configured yet.");
|
|
886
|
+
return;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const key = await resolveProxyKey(flags, configDir, config);
|
|
890
|
+
if (!key) {
|
|
891
|
+
console.log("Clawtamer appears configured locally but no proxy key was found.");
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
let savings = null;
|
|
896
|
+
try {
|
|
897
|
+
const r = await httpJson(`${apiBase}/v1/savings`, { headers: { "x-api-key": key } });
|
|
898
|
+
if (r.ok) savings = r.data;
|
|
899
|
+
} catch {
|
|
900
|
+
// ignore
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
console.log(bold(info("Clawtamer status")));
|
|
904
|
+
console.log(`${dim("-")} config: ${configPath}`);
|
|
905
|
+
console.log(`${dim("-")} proxy: ${provider.baseUrl}`);
|
|
906
|
+
console.log(`${dim("-")} key: ${mask(key)}`);
|
|
907
|
+
if (savings) {
|
|
908
|
+
console.log(`${dim("-")} requests: ${savings.requests}`);
|
|
909
|
+
console.log(`${dim("-")} saved_usd_lifetime: ${savings.saved_usd_lifetime}`);
|
|
910
|
+
console.log(`${dim("-")} monthly_message_count: ${savings.monthly_message_count}`);
|
|
911
|
+
if (savings.paywall_status) {
|
|
912
|
+
console.log(`${dim("-")} paywall_status: ${savings.paywall_status}`);
|
|
913
|
+
}
|
|
914
|
+
if (Number.isFinite(Number(savings.paywall_remaining_hours))) {
|
|
915
|
+
console.log(`${dim("-")} paywall_remaining_hours: ${savings.paywall_remaining_hours}`);
|
|
916
|
+
}
|
|
917
|
+
if (Number.isFinite(Number(savings.paywall_remaining_messages))) {
|
|
918
|
+
console.log(`${dim("-")} paywall_remaining_messages: ${savings.paywall_remaining_messages}`);
|
|
919
|
+
}
|
|
920
|
+
if (savings.paywall_upgrade_url) {
|
|
921
|
+
console.log(`${dim("-")} upgrade_url: ${savings.paywall_upgrade_url}`);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function cmdTest(flags) {
|
|
927
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
928
|
+
const found = findOpenClawConfig(baseDir);
|
|
929
|
+
if (!found) {
|
|
930
|
+
console.error("No OpenClaw config found.");
|
|
931
|
+
process.exit(1);
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
const { configPath, config } = found;
|
|
935
|
+
const configDir = path.dirname(configPath);
|
|
936
|
+
const apiBase = getApiBase(flags, config);
|
|
937
|
+
logStep(flags, `Running proxy test against: ${apiBase}`);
|
|
938
|
+
const key = await resolveProxyKey(flags, configDir, config);
|
|
939
|
+
|
|
940
|
+
if (!key) {
|
|
941
|
+
console.error("No Clawtamer key found.");
|
|
942
|
+
process.exit(1);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
ensureProxyKey(key);
|
|
946
|
+
|
|
947
|
+
const payload = {
|
|
948
|
+
model: "claude-sonnet-4-5",
|
|
949
|
+
max_tokens: 24,
|
|
950
|
+
messages: [{ role: "user", content: "Reply with the word: healthy" }]
|
|
951
|
+
};
|
|
952
|
+
|
|
953
|
+
const r = await httpJson(`${apiBase}/v1/messages`, {
|
|
954
|
+
method: "POST",
|
|
955
|
+
headers: { "content-type": "application/json", "x-api-key": key },
|
|
956
|
+
body: JSON.stringify(payload)
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
if (!r.ok) {
|
|
960
|
+
console.error("Test failed:", r.data?.error || r.data);
|
|
961
|
+
process.exit(1);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
console.log(success("Proxy test passed."));
|
|
965
|
+
console.log(`${dim("-")} routed model: ${r.headers.get("x-clawtamer-routed-model") || "n/a"}`);
|
|
966
|
+
console.log(`${dim("-")} saved usd: ${r.headers.get("x-clawtamer-saved-usd") || "n/a"}`);
|
|
967
|
+
console.log(`${dim("-")} request id: ${r.headers.get("x-clawtamer-request-id") || "n/a"}`);
|
|
968
|
+
console.log(`${dim("-")} paywall status: ${r.headers.get("x-clawtamer-paywall-status") || "n/a"}`);
|
|
969
|
+
console.log(`${dim("-")} upgrade url: ${r.headers.get("x-clawtamer-upgrade-url") || "n/a"}`);
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
async function cmdSavings(flags) {
|
|
973
|
+
const rawPeriod = String(flags._?.[0] || "7d").toLowerCase();
|
|
974
|
+
const period = rawPeriod === "24h" ? "7d" : rawPeriod;
|
|
975
|
+
const allowed = new Set(["7d", "30d", "lifetime"]);
|
|
976
|
+
if (!allowed.has(period)) {
|
|
977
|
+
console.error(danger(`Invalid period: ${rawPeriod}. Use one of: 7d, 30d, lifetime`));
|
|
978
|
+
process.exit(1);
|
|
979
|
+
}
|
|
980
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
981
|
+
const found = findOpenClawConfig(baseDir);
|
|
982
|
+
if (!found) {
|
|
983
|
+
console.error("No OpenClaw config found.");
|
|
984
|
+
process.exit(1);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
const { configPath, config } = found;
|
|
988
|
+
const configDir = path.dirname(configPath);
|
|
989
|
+
const apiBase = getApiBase(flags, config);
|
|
990
|
+
logStep(flags, `Fetching insights (${period}) from: ${apiBase}`);
|
|
991
|
+
const key = await resolveProxyKey(flags, configDir, config);
|
|
992
|
+
|
|
993
|
+
if (!key) {
|
|
994
|
+
console.error("No Clawtamer key found.");
|
|
995
|
+
process.exit(1);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const [lifetimeRes, periodRes] = await Promise.all([
|
|
999
|
+
httpJson(`${apiBase}/v1/savings`, { headers: { "x-api-key": key } }),
|
|
1000
|
+
period === "lifetime"
|
|
1001
|
+
? Promise.resolve({ ok: true, data: null })
|
|
1002
|
+
: httpJson(`${apiBase}/v1/insights?period=${encodeURIComponent(period)}`, {
|
|
1003
|
+
headers: { "x-api-key": key }
|
|
1004
|
+
})
|
|
1005
|
+
]);
|
|
1006
|
+
|
|
1007
|
+
if (!lifetimeRes.ok) {
|
|
1008
|
+
console.error(danger("Failed to fetch lifetime savings:"), lifetimeRes.data?.error || lifetimeRes.data);
|
|
1009
|
+
process.exit(1);
|
|
1010
|
+
}
|
|
1011
|
+
if (!periodRes.ok) {
|
|
1012
|
+
console.error(danger("Failed to fetch period insights:"), periodRes.data?.error || periodRes.data);
|
|
1013
|
+
process.exit(1);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
console.log(bold(info("Savings")));
|
|
1017
|
+
console.log(`${dim("-")} lifetime_saved_usd: ${lifetimeRes.data.saved_usd_lifetime}`);
|
|
1018
|
+
console.log(`${dim("-")} lifetime_requests: ${lifetimeRes.data.requests}`);
|
|
1019
|
+
console.log(`${dim("-")} monthly_message_count: ${lifetimeRes.data.monthly_message_count}`);
|
|
1020
|
+
|
|
1021
|
+
if (period !== "lifetime" && periodRes.data) {
|
|
1022
|
+
console.log(`${dim("-")} period: ${periodRes.data.period || period}`);
|
|
1023
|
+
console.log(`${dim("-")} period_requests: ${periodRes.data.total_requests}`);
|
|
1024
|
+
console.log(`${dim("-")} period_saved_tokens: ${periodRes.data.saved_tokens}`);
|
|
1025
|
+
console.log(`${dim("-")} period_estimated_savings_usd: ${periodRes.data.estimated_savings_usd}`);
|
|
1026
|
+
if (periodRes.data.routing_breakdown) {
|
|
1027
|
+
console.log(
|
|
1028
|
+
`${dim("-")} routing: simple ${periodRes.data.routing_breakdown.simple}% | mid ${periodRes.data.routing_breakdown.mid}% | complex ${periodRes.data.routing_breakdown.complex}% | reasoning ${periodRes.data.routing_breakdown.reasoning}%`
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async function cmdStoreKey(flags) {
|
|
1035
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
1036
|
+
const found = findOpenClawConfig(baseDir);
|
|
1037
|
+
if (!found) {
|
|
1038
|
+
console.error("No OpenClaw config found.");
|
|
1039
|
+
process.exit(1);
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const { configPath, config } = found;
|
|
1043
|
+
const configDir = path.dirname(configPath);
|
|
1044
|
+
const apiBase = getApiBase(flags, config);
|
|
1045
|
+
logStep(flags, `Rotating upstream key via: ${apiBase}`);
|
|
1046
|
+
const proxyKey = await resolveProxyKey(flags, configDir, config);
|
|
1047
|
+
|
|
1048
|
+
if (!proxyKey) {
|
|
1049
|
+
console.error("No Clawtamer proxy key found.");
|
|
1050
|
+
process.exit(1);
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
const upstreamKey = flags["--upstream-key"] || (!flags["--proxy-key"] ? flags["--key"] : null) || (await promptText("New Anthropic API key: ", { secret: true }));
|
|
1054
|
+
ensureAnthropicKey(upstreamKey);
|
|
1055
|
+
|
|
1056
|
+
const r = await httpJson(`${apiBase}/v1/store-key`, {
|
|
1057
|
+
method: "POST",
|
|
1058
|
+
headers: { "content-type": "application/json", "x-api-key": proxyKey },
|
|
1059
|
+
body: JSON.stringify({ upstream_key: upstreamKey })
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
if (!r.ok) {
|
|
1063
|
+
console.error("Failed to store key:", r.data?.error || r.data);
|
|
1064
|
+
process.exit(1);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
console.log(success(`Upstream key rotated successfully. last4=${r.data.key_last4}`));
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
async function cmdRestore(flags) {
|
|
1071
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
1072
|
+
const found = findOpenClawConfig(baseDir);
|
|
1073
|
+
if (!found) {
|
|
1074
|
+
console.error("No OpenClaw config found.");
|
|
1075
|
+
process.exit(1);
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
const { configPath, config } = found;
|
|
1079
|
+
const configDir = path.dirname(configPath);
|
|
1080
|
+
const metadata = loadMetadata(configDir);
|
|
1081
|
+
|
|
1082
|
+
if (!flags["--yes"]) {
|
|
1083
|
+
const proceed = await confirm("Remove Clawtamer from this OpenClaw config? (y/N)", true);
|
|
1084
|
+
if (!proceed) return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
if (config.models?.providers?.clawtamer) {
|
|
1088
|
+
delete config.models.providers.clawtamer;
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
ensureObjectPath(config, ["agents", "defaults", "model"]);
|
|
1092
|
+
|
|
1093
|
+
if (metadata.originalModelConfig && typeof metadata.originalModelConfig === "object") {
|
|
1094
|
+
config.agents.defaults.model = metadata.originalModelConfig;
|
|
1095
|
+
} else if (metadata.originalModel) {
|
|
1096
|
+
config.agents.defaults.model.primary = metadata.originalModel;
|
|
1097
|
+
delete config.agents.defaults.model.fallbacks;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// Restore allowlist state as it existed before install, if we captured it.
|
|
1101
|
+
if (Object.prototype.hasOwnProperty.call(metadata, "originalDefaultsModels")) {
|
|
1102
|
+
if (metadata.originalDefaultsModels && typeof metadata.originalDefaultsModels === "object") {
|
|
1103
|
+
config.agents.defaults.models = metadata.originalDefaultsModels;
|
|
1104
|
+
} else if (config.agents.defaults.models && typeof config.agents.defaults.models === "object") {
|
|
1105
|
+
delete config.agents.defaults.models["clawtamer/auto"];
|
|
1106
|
+
delete config.agents.defaults.models["clawtamer/claude-haiku-4-5"];
|
|
1107
|
+
delete config.agents.defaults.models["clawtamer/claude-sonnet-4-5"];
|
|
1108
|
+
delete config.agents.defaults.models["clawtamer/claude-opus-4-6"];
|
|
1109
|
+
if (Object.keys(config.agents.defaults.models).length === 0) {
|
|
1110
|
+
delete config.agents.defaults.models;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
writeConfig(configPath, config);
|
|
1116
|
+
clearMetadata(configDir);
|
|
1117
|
+
|
|
1118
|
+
console.log(success("Clawtamer restore complete (provider removed)."));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
async function cmdDoctor(flags) {
|
|
1122
|
+
const issues = [];
|
|
1123
|
+
const warns = [];
|
|
1124
|
+
const passes = [];
|
|
1125
|
+
|
|
1126
|
+
const major = Number(process.versions.node.split(".")[0]);
|
|
1127
|
+
if (major >= 22) passes.push(`Node.js ${process.versions.node}`);
|
|
1128
|
+
else issues.push(`Node.js ${process.versions.node} (requires >=22)`);
|
|
1129
|
+
|
|
1130
|
+
const baseDir = flags["--dir"] || process.cwd();
|
|
1131
|
+
const found = findOpenClawConfig(baseDir);
|
|
1132
|
+
if (!found) {
|
|
1133
|
+
issues.push("No OpenClaw config found");
|
|
1134
|
+
} else {
|
|
1135
|
+
passes.push(`OpenClaw config found at ${found.configPath}`);
|
|
1136
|
+
const provider = found.config?.models?.providers?.clawtamer;
|
|
1137
|
+
if (provider) passes.push("Clawtamer provider configured");
|
|
1138
|
+
else warns.push("Clawtamer provider not configured yet (run init)");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (issues.length) {
|
|
1142
|
+
console.log(danger("[FAIL]"));
|
|
1143
|
+
issues.forEach((i) => console.log(`- ${i}`));
|
|
1144
|
+
warns.forEach((w) => console.log(`${warn("- [WARN]")} ${w}`));
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
console.log(success("[PASS]"));
|
|
1149
|
+
passes.forEach((p) => console.log(`- ${p}`));
|
|
1150
|
+
warns.forEach((w) => console.log(`${warn("- [WARN]")} ${w}`));
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async function main() {
|
|
1154
|
+
const { command, flags } = parseArgs(process.argv);
|
|
1155
|
+
|
|
1156
|
+
if (flags["--help"]) {
|
|
1157
|
+
banner();
|
|
1158
|
+
console.log("");
|
|
1159
|
+
console.log(HELP);
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
if (flags["--version"]) {
|
|
1164
|
+
const pkg = require("../package.json");
|
|
1165
|
+
console.log(pkg.version);
|
|
1166
|
+
return;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
switch (command) {
|
|
1170
|
+
case "init":
|
|
1171
|
+
await cmdInit(flags);
|
|
1172
|
+
break;
|
|
1173
|
+
case "login":
|
|
1174
|
+
await cmdInit({ ...flags, "--existing-user": true });
|
|
1175
|
+
break;
|
|
1176
|
+
case "status":
|
|
1177
|
+
await cmdStatus(flags);
|
|
1178
|
+
break;
|
|
1179
|
+
case "test":
|
|
1180
|
+
await cmdTest(flags);
|
|
1181
|
+
break;
|
|
1182
|
+
case "savings":
|
|
1183
|
+
await cmdSavings(flags);
|
|
1184
|
+
break;
|
|
1185
|
+
case "doctor":
|
|
1186
|
+
await cmdDoctor(flags);
|
|
1187
|
+
break;
|
|
1188
|
+
case "store-key":
|
|
1189
|
+
await cmdStoreKey(flags);
|
|
1190
|
+
break;
|
|
1191
|
+
case "restore":
|
|
1192
|
+
case "remove":
|
|
1193
|
+
await cmdRestore(flags);
|
|
1194
|
+
break;
|
|
1195
|
+
default:
|
|
1196
|
+
console.error(`Unknown command: ${command}`);
|
|
1197
|
+
console.log(HELP);
|
|
1198
|
+
process.exit(1);
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
main().catch((err) => {
|
|
1203
|
+
console.error(err.message || err);
|
|
1204
|
+
process.exit(1);
|
|
1205
|
+
});
|