@openviking/openclaw-plugin 2026.5.21 → 2026.5.26

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/commands/setup.ts CHANGED
@@ -1,1002 +1,1002 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
- import * as readline from "node:readline";
5
- import { fileURLToPath } from "node:url";
6
- import { getEnv } from "../runtime-utils.js";
7
-
8
- const HOME = os.homedir();
9
- const OPENCLAW_DIR = getEnv("OPENCLAW_STATE_DIR") || path.join(HOME, ".openclaw");
10
- const DEFAULT_REMOTE_URL = "http://127.0.0.1:1933";
11
-
12
- function findPluginPackageRoot(fromDir = path.dirname(fileURLToPath(import.meta.url))): string | null {
13
- let current = path.resolve(fromDir);
14
- for (let depth = 0; depth < 5; depth += 1) {
15
- if (
16
- fs.existsSync(path.join(current, "package.json")) &&
17
- fs.existsSync(path.join(current, "openclaw.plugin.json"))
18
- ) {
19
- return current;
20
- }
21
-
22
- const parent = path.dirname(current);
23
- if (parent === current) break;
24
- current = parent;
25
- }
26
- return null;
27
- }
28
-
29
- function readPluginVersion(): string {
30
- try {
31
- const packageRoot = findPluginPackageRoot();
32
- if (!packageRoot) return "unknown";
33
- const pkgPath = path.join(packageRoot, "package.json");
34
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
35
- return String(pkg.version ?? "unknown");
36
- } catch {
37
- return "unknown";
38
- }
39
- }
40
-
41
- function readCompatRangeFromManifest(): { min: string; max: string } {
42
- try {
43
- const packageRoot = findPluginPackageRoot();
44
- if (!packageRoot) return { min: "", max: "" };
45
- const manifestPath = path.join(packageRoot, "install-manifest.json");
46
- const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
47
- const compat = manifest?.compatibility ?? {};
48
- return {
49
- min: String(compat.minOpenvikingVersion ?? ""),
50
- max: String(compat.maxOpenvikingVersion ?? ""),
51
- };
52
- } catch {
53
- return { min: "", max: "" };
54
- }
55
- }
56
-
57
- const PLUGIN_VERSION = readPluginVersion();
58
- const { min: COMPATIBLE_SERVER_MIN, max: COMPATIBLE_SERVER_MAX } = readCompatRangeFromManifest();
59
-
60
- type CommandProgram = {
61
- command: (name: string) => CommandBuilder;
62
- };
63
-
64
- type CommandBuilder = {
65
- description: (desc: string) => CommandBuilder;
66
- option: (flags: string, desc: string) => CommandBuilder;
67
- command: (name: string) => CommandBuilder;
68
- action: (fn: (...args: unknown[]) => void | Promise<void>) => CommandBuilder;
69
- };
70
-
71
- type RegisterCliArgs = {
72
- program: CommandProgram;
73
- };
74
-
75
- function tr(langZh: boolean, en: string, zh: string): string {
76
- return langZh ? zh : en;
77
- }
78
-
79
- function maskKey(key: string): string {
80
- if (key.length <= 8) return "****";
81
- return `${key.slice(0, 4)}...${key.slice(-4)}`;
82
- }
83
-
84
- function isValidAgentPrefixInput(value: string): boolean {
85
- const trimmed = value.trim();
86
- return !trimmed || /^[a-zA-Z0-9_-]+$/.test(trimmed);
87
- }
88
-
89
- async function askAgentPrefix(
90
- zh: boolean,
91
- q: (prompt: string, def?: string) => Promise<string>,
92
- defaultValue: string,
93
- ): Promise<string> {
94
- while (true) {
95
- const value = (await q(
96
- tr(zh, "Agent Prefix (optional)", "Agent Prefix(可选)"),
97
- defaultValue,
98
- )).trim();
99
- if (isValidAgentPrefixInput(value)) {
100
- return value;
101
- }
102
- console.log(
103
- ` ✗ ${tr(
104
- zh,
105
- "Agent Prefix may only contain letters, digits, underscores, and hyphens, or be empty.",
106
- "Agent Prefix 只能包含字母、数字、下划线和连字符,或留空。",
107
- )}`,
108
- );
109
- }
110
- }
111
-
112
- function ask(rl: readline.Interface, prompt: string, defaultValue = ""): Promise<string> {
113
- const suffix = defaultValue ? ` [${defaultValue}]` : "";
114
- return new Promise((resolve) => {
115
- rl.question(`${prompt}${suffix}: `, (answer) => {
116
- resolve((answer ?? "").trim() || defaultValue);
117
- });
118
- });
119
- }
120
-
121
- type VersionCompatibility = "compatible" | "server_too_old" | "server_too_new" | "unknown";
122
-
123
- type HealthResult = {
124
- ok: boolean;
125
- version: string;
126
- error: string;
127
- compatibility: VersionCompatibility;
128
- pluginVersion: string;
129
- compatRange: string;
130
- };
131
-
132
- function parseVersionTuple(v: string): number[] | null {
133
- const cleaned = v.replace(/^v/i, "").split("-")[0];
134
- const parts = cleaned.split(".").map(Number);
135
- if (parts.some(isNaN)) return null;
136
- return parts;
137
- }
138
-
139
- function compareVersions(a: number[], b: number[]): number {
140
- const len = Math.max(a.length, b.length);
141
- for (let i = 0; i < len; i++) {
142
- const diff = (a[i] ?? 0) - (b[i] ?? 0);
143
- if (diff !== 0) return diff;
144
- }
145
- return 0;
146
- }
147
-
148
- function checkVersionCompatibility(serverVersion: string): VersionCompatibility {
149
- if (!serverVersion) return "unknown";
150
- const sv = parseVersionTuple(serverVersion);
151
- if (!sv) return "unknown";
152
-
153
- if (COMPATIBLE_SERVER_MIN) {
154
- const minV = parseVersionTuple(COMPATIBLE_SERVER_MIN);
155
- if (minV && compareVersions(sv, minV) < 0) return "server_too_old";
156
- }
157
- if (COMPATIBLE_SERVER_MAX) {
158
- const maxV = parseVersionTuple(COMPATIBLE_SERVER_MAX);
159
- if (maxV && compareVersions(sv, maxV) > 0) return "server_too_new";
160
- }
161
- return "compatible";
162
- }
163
-
164
- function formatCompatRange(): string {
165
- if (COMPATIBLE_SERVER_MIN && COMPATIBLE_SERVER_MAX) return `${COMPATIBLE_SERVER_MIN} ~ ${COMPATIBLE_SERVER_MAX}`;
166
- if (COMPATIBLE_SERVER_MIN) return `>= ${COMPATIBLE_SERVER_MIN}`;
167
- if (COMPATIBLE_SERVER_MAX) return `<= ${COMPATIBLE_SERVER_MAX}`;
168
- return "any";
169
- }
170
-
171
- type ApiKeyProbeResult = {
172
- keyType: "user_key" | "root_key" | "no_key" | "unknown";
173
- needsAccountId: boolean;
174
- needsUserId: boolean;
175
- detail: string;
176
- };
177
-
178
- async function probeApiKeyType(baseUrl: string, apiKey?: string): Promise<ApiKeyProbeResult> {
179
- if (!apiKey) return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
180
-
181
- const controller = new AbortController();
182
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
183
- const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
184
- try {
185
- const headers: Record<string, string> = { "X-API-Key": apiKey };
186
- const response = await fetch(sessionsUrl, {
187
- headers,
188
- signal: controller.signal,
189
- });
190
-
191
- if (response.ok) {
192
- return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
193
- }
194
-
195
- // Server raises InvalidArgumentError (-> HTTP 400) when a ROOT key calls
196
- // tenant-scoped endpoints without X-OpenViking-Account / X-OpenViking-User.
197
- // 401/403 may also be returned by older versions, FastAPI validation can
198
- // surface as 422. Treat all four as candidates for tenant-context errors.
199
- if ([400, 401, 403, 422].includes(response.status)) {
200
- let body = "";
201
- try {
202
- body = await response.text();
203
- } catch { /* ignore parse errors */ }
204
- const lower = body.toLowerCase();
205
- const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
206
- const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
207
- if (needsAccount || needsUser) {
208
- return {
209
- keyType: "root_key",
210
- needsAccountId: needsAccount,
211
- needsUserId: needsUser,
212
- detail: body.slice(0, 200),
213
- };
214
- }
215
-
216
- // Body did not name account/user/tenant explicitly (custom auth middleware,
217
- // localized message, etc.). Re-probe with placeholder tenant headers; if
218
- // the failure was due to missing tenant headers the response will change.
219
- try {
220
- const probeHeaders: Record<string, string> = {
221
- "X-API-Key": apiKey,
222
- "X-OpenViking-Account": "__probe__",
223
- "X-OpenViking-User": "__probe__",
224
- };
225
- const probe2 = await fetch(sessionsUrl, {
226
- headers: probeHeaders,
227
- signal: controller.signal,
228
- });
229
- if (probe2.status !== response.status) {
230
- return {
231
- keyType: "root_key",
232
- needsAccountId: true,
233
- needsUserId: true,
234
- detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
235
- };
236
- }
237
- } catch { /* ignore probe errors, fall through to unknown */ }
238
-
239
- if (response.status === 401 || response.status === 403) {
240
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
241
- }
242
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
243
- }
244
-
245
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
246
- } catch (err) {
247
- return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
248
- } finally {
249
- clearTimeout(timeoutId);
250
- }
251
- }
252
-
253
- async function checkServiceHealth(baseUrl: string, apiKey?: string): Promise<HealthResult> {
254
- const controller = new AbortController();
255
- const timeoutId = setTimeout(() => controller.abort(), 10_000);
256
- try {
257
- const headers: Record<string, string> = {};
258
- if (apiKey) headers["X-API-Key"] = apiKey;
259
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
260
- headers,
261
- signal: controller.signal,
262
- });
263
- if (response.ok) {
264
- try {
265
- const data = await response.json() as Record<string, unknown>;
266
- const result = (data.result ?? data) as Record<string, unknown>;
267
- const version = String(result.version ?? data.version ?? "");
268
- const compatibility = checkVersionCompatibility(version);
269
- return { ok: true, version, error: "", compatibility, pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
270
- } catch {
271
- return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
272
- }
273
- }
274
- return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
275
- } catch (err) {
276
- return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
277
- } finally {
278
- clearTimeout(timeoutId);
279
- }
280
- }
281
-
282
- function readOpenClawConfig(configPath: string): Record<string, unknown> {
283
- if (!fs.existsSync(configPath)) return {};
284
- try {
285
- return JSON.parse(fs.readFileSync(configPath, "utf-8"));
286
- } catch {
287
- return {};
288
- }
289
- }
290
-
291
- function getExistingPluginConfig(config: Record<string, unknown>): Record<string, unknown> | null {
292
- const plugins = config.plugins as Record<string, unknown> | undefined;
293
- if (!plugins) return null;
294
- const entries = plugins.entries as Record<string, unknown> | undefined;
295
- if (!entries) return null;
296
- const entry = entries.openviking as Record<string, unknown> | undefined;
297
- if (!entry) return null;
298
- const cfg = entry.config as Record<string, unknown> | undefined;
299
- return cfg && cfg.mode ? cfg : null;
300
- }
301
-
302
- function backupConfig(configPath: string): string | null {
303
- if (!fs.existsSync(configPath)) return null;
304
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
305
- const backupPath = `${configPath}.bak.${timestamp}`;
306
- try {
307
- fs.copyFileSync(configPath, backupPath);
308
- return backupPath;
309
- } catch {
310
- return null;
311
- }
312
- }
313
-
314
- function ensureInstallRecord(plugins: Record<string, unknown>): void {
315
- const installs = plugins.installs as Record<string, unknown> | undefined;
316
- if (installs && typeof installs === "object") {
317
- delete installs.openviking;
318
- }
319
-
320
- if (!plugins.allow) plugins.allow = [];
321
- const allow = plugins.allow as string[];
322
- if (!allow.includes("openviking")) {
323
- allow.push("openviking");
324
- }
325
- }
326
-
327
- function writeConfig(
328
- configPath: string,
329
- pluginCfg: Record<string, unknown>,
330
- ): void {
331
- const configDir = path.dirname(configPath);
332
- if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
333
-
334
- backupConfig(configPath);
335
-
336
- const config = readOpenClawConfig(configPath);
337
-
338
- if (!config.plugins) config.plugins = {};
339
- const plugins = config.plugins as Record<string, unknown>;
340
- if (!plugins.entries) plugins.entries = {};
341
- const entries = plugins.entries as Record<string, unknown>;
342
-
343
- const existingEntry = (entries.openviking as Record<string, unknown>) ?? {};
344
- entries.openviking = { ...existingEntry, config: pluginCfg };
345
-
346
- ensureInstallRecord(plugins);
347
-
348
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
349
- }
350
-
351
- function detectLangZh(options: Record<string, unknown>): boolean {
352
- if (options.zh) return true;
353
- const lang = getEnv("LANG") || getEnv("LC_ALL") || "";
354
- return /^zh/i.test(lang);
355
- }
356
-
357
- function isLegacyLocalMode(existing: Record<string, unknown>): boolean {
358
- const mode = existing.mode;
359
- return mode !== "remote";
360
- }
361
-
362
- type SetupResult = {
363
- success: boolean;
364
- action: "configured" | "existing" | "error" | "slot_blocked";
365
- config?: {
366
- mode: string;
367
- baseUrl: string;
368
- apiKey?: string;
369
- agent_prefix?: string;
370
- accountId?: string;
371
- userId?: string;
372
- };
373
- health?: HealthResult;
374
- keyProbe?: ApiKeyProbeResult;
375
- slot: SlotActivationResult;
376
- error?: string;
377
- };
378
-
379
- function setExitCodeOnFailure(result: { success: boolean }): void {
380
- if (!result.success) {
381
- process.exitCode = 1;
382
- }
383
- }
384
-
385
- type StatusResult = {
386
- configured: boolean;
387
- config?: {
388
- mode: string;
389
- baseUrl: string;
390
- hasApiKey: boolean;
391
- agent_prefix?: string;
392
- hasAccountId: boolean;
393
- hasUserId: boolean;
394
- };
395
- health?: HealthResult;
396
- keyProbe?: ApiKeyProbeResult;
397
- slotActive: boolean;
398
- };
399
-
400
- type SlotActivationResult = {
401
- activated: boolean;
402
- previousOwner?: string;
403
- replaced: boolean;
404
- };
405
-
406
- function activateContextEngineSlot(configPath: string, force = false): SlotActivationResult {
407
- const config = readOpenClawConfig(configPath);
408
- if (!config.plugins) config.plugins = {};
409
- const plugins = config.plugins as Record<string, unknown>;
410
- if (!plugins.slots) plugins.slots = {};
411
- const slots = plugins.slots as Record<string, unknown>;
412
-
413
- const current = slots.contextEngine as string | undefined;
414
-
415
- if (current === "openviking") return { activated: false, replaced: false };
416
-
417
- if (current && current !== "openviking" && !force) {
418
- return { activated: false, previousOwner: current, replaced: false };
419
- }
420
-
421
- slots.contextEngine = "openviking";
422
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
423
- return { activated: true, previousOwner: current || undefined, replaced: !!current };
424
- }
425
-
426
- function isContextEngineSlotActive(configPath: string): boolean {
427
- const config = readOpenClawConfig(configPath);
428
- const plugins = config.plugins as Record<string, unknown> | undefined;
429
- if (!plugins) return false;
430
- const slots = plugins.slots as Record<string, unknown> | undefined;
431
- return slots?.contextEngine === "openviking";
432
- }
433
-
434
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
- export function registerSetupCli(api: any): void {
436
- if (!api.registerCli) {
437
- api.logger.info("openviking: registerCli not available, setup command skipped");
438
- return;
439
- }
440
-
441
- api.registerCli(
442
- ({ program }: RegisterCliArgs) => {
443
- const ovCmd = program.command("openviking").description("OpenViking plugin commands");
444
-
445
- ovCmd
446
- .command("setup")
447
- .description("Setup OpenViking plugin (supports both interactive and non-interactive modes)")
448
- .option("--reconfigure", "Force re-entry of all configuration values")
449
- .option("--zh", "Chinese prompts")
450
- .option("--base-url <url>", "OpenViking server URL (enables non-interactive mode)")
451
- .option("--api-key <key>", "API key for authentication")
452
- .option("--agent-prefix <prefix>", "Agent routing prefix for namespace isolation")
453
- .option("--account-id <id>", "Account ID (required for root API keys)")
454
- .option("--user-id <id>", "User ID (required for root API keys)")
455
- .option("--allow-offline", "Allow config write even if server is unreachable")
456
- .option("--force-slot", "Replace existing contextEngine slot even if owned by another plugin")
457
- .option("--json", "Output result as JSON (machine-readable)")
458
- .action(async (...args: unknown[]) => {
459
- const options = (args[0] ?? {}) as Record<string, unknown>;
460
- const {
461
- reconfigure, zh: zhOpt, baseUrl, apiKey, agentPrefix,
462
- accountId, userId, allowOffline, forceSlot, json: jsonOpt,
463
- } = options as {
464
- reconfigure?: boolean; zh?: boolean; baseUrl?: string;
465
- apiKey?: string; agentPrefix?: string; accountId?: string;
466
- userId?: string; allowOffline?: boolean; forceSlot?: boolean;
467
- json?: boolean;
468
- };
469
- const zh = detectLangZh(options);
470
- const configPath = path.join(OPENCLAW_DIR, "openclaw.json");
471
- const jsonMode = !!jsonOpt;
472
- const nonInteractive = !!baseUrl;
473
-
474
- if (nonInteractive) {
475
- const result = await setupNonInteractive(configPath, {
476
- baseUrl: baseUrl!,
477
- apiKey,
478
- agentPrefix,
479
- accountId,
480
- userId,
481
- allowOffline: !!allowOffline,
482
- forceSlot: !!forceSlot,
483
- });
484
- if (jsonMode) {
485
- console.log(JSON.stringify(result, null, 2));
486
- } else {
487
- printSetupResult(zh, result);
488
- }
489
- setExitCodeOnFailure(result);
490
- return;
491
- }
492
-
493
- if (jsonMode && !nonInteractive) {
494
- const result: SetupResult = {
495
- success: false,
496
- action: "error",
497
- slot: { activated: false, replaced: false },
498
- error: "--json requires --base-url for non-interactive mode",
499
- };
500
- console.log(JSON.stringify(result, null, 2));
501
- setExitCodeOnFailure(result);
502
- return;
503
- }
504
-
505
- console.log("");
506
- console.log(`🦣 ${tr(zh, "OpenViking Plugin Setup", "OpenViking 插件配置向导")}`);
507
- console.log("");
508
-
509
- const config = readOpenClawConfig(configPath);
510
- const existing = getExistingPluginConfig(config);
511
-
512
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
513
- const q = (prompt: string, def = "") => ask(rl, prompt, def);
514
-
515
- try {
516
- if (existing && !reconfigure) {
517
- if (isLegacyLocalMode(existing)) {
518
- console.log(tr(
519
- zh,
520
- "Existing configuration uses local mode, which is no longer supported.",
521
- "当前配置为本地模式,已不再支持。",
522
- ));
523
- console.log(tr(
524
- zh,
525
- "Run `openclaw openviking setup --reconfigure` to configure a remote OpenViking server.",
526
- "请运行 `openclaw openviking setup --reconfigure` 以配置远程 OpenViking 服务。",
527
- ));
528
- console.log("");
529
- return;
530
- }
531
-
532
- console.log(tr(zh, "Existing configuration found:", "已找到现有配置:"));
533
- console.log(` mode: ${existing.mode}`);
534
- console.log(` baseUrl: ${existing.baseUrl ?? DEFAULT_REMOTE_URL}`);
535
- if (existing.apiKey) console.log(` apiKey: ${maskKey(String(existing.apiKey))}`);
536
- if (existing.agent_prefix) console.log(` agent_prefix: ${existing.agent_prefix}`);
537
- console.log("");
538
- console.log(tr(
539
- zh,
540
- "Press Enter to keep existing values, or use --reconfigure to change.",
541
- "按 Enter 保留现有配置,或使用 --reconfigure 重新配置。",
542
- ));
543
- console.log("");
544
- console.log(tr(zh, "✓ Using existing configuration", "✓ 使用现有配置"));
545
- console.log("");
546
-
547
- await runRemoteCheck(zh, existing);
548
-
549
- const slotResult = activateContextEngineSlot(configPath);
550
- printSlotResult(zh, slotResult);
551
-
552
- console.log(tr(zh,
553
- "✓ Plugin is ready. Run `openclaw gateway --force` to activate.",
554
- "✓ 插件已就绪。运行 `openclaw gateway --force` 以激活。",
555
- ));
556
- console.log("");
557
- return;
558
- }
559
-
560
- if (existing && options.reconfigure) {
561
- console.log(tr(zh, "Existing configuration found:", "已找到现有配置:"));
562
- if (isLegacyLocalMode(existing)) {
563
- console.log(tr(zh,
564
- "(Previous local-mode settings will be replaced with remote settings.)",
565
- "(将用远程模式设置替换此前的本地模式配置。)",
566
- ));
567
- } else {
568
- console.log(` mode: ${existing.mode}`);
569
- console.log(` baseUrl: ${existing.baseUrl ?? DEFAULT_REMOTE_URL}`);
570
- if (existing.apiKey) console.log(` apiKey: ${maskKey(String(existing.apiKey))}`);
571
- }
572
- console.log("");
573
- console.log(tr(zh, "Reconfiguring...", "重新配置中..."));
574
- console.log("");
575
- } else {
576
- console.log(tr(zh,
577
- "No existing configuration found. Starting setup wizard.",
578
- "未找到现有配置,开始配置向导。",
579
- ));
580
- console.log("");
581
- }
582
-
583
- await setupRemote(zh, configPath, existing, q);
584
- } finally {
585
- rl.close();
586
- }
587
- });
588
-
589
- ovCmd
590
- .command("status")
591
- .description("Show current OpenViking plugin status and connectivity")
592
- .option("--zh", "Chinese prompts")
593
- .option("--json", "Output result as JSON (machine-readable)")
594
- .action(async (...args: unknown[]) => {
595
- const options = (args[0] ?? {}) as Record<string, unknown>;
596
- const { zh: zhOpt, json: jsonOpt } = options as { zh?: boolean; json?: boolean };
597
- const zh = detectLangZh(options);
598
- const configPath = path.join(OPENCLAW_DIR, "openclaw.json");
599
- const jsonMode = !!jsonOpt;
600
-
601
- const result = await getStatus(configPath);
602
-
603
- if (jsonMode) {
604
- console.log(JSON.stringify(result, null, 2));
605
- return;
606
- }
607
-
608
- printStatus(zh, result);
609
- });
610
- },
611
- { commands: ["openviking"] },
612
- );
613
- }
614
-
615
- function printCompatibilityWarning(zh: boolean, health: HealthResult): void {
616
- if (health.compatibility === "server_too_old") {
617
- console.log(` ⚠ ${tr(zh,
618
- `Server version ${health.version} is older than recommended (${health.compatRange}). Some features may not work. Please upgrade OpenViking server.`,
619
- `服务端版本 ${health.version} 低于推荐范围(${health.compatRange})。部分功能可能不可用,请升级 OpenViking 服务端。`,
620
- )}`);
621
- } else if (health.compatibility === "server_too_new") {
622
- console.log(` ⚠ ${tr(zh,
623
- `Server version ${health.version} is newer than supported (${health.compatRange}). Please upgrade the OpenViking plugin.`,
624
- `服务端版本 ${health.version} 高于插件支持范围(${health.compatRange})。请升级 OpenViking 插件。`,
625
- )}`);
626
- } else if (health.compatibility === "unknown" && health.ok) {
627
- console.log(` ⚠ ${tr(zh,
628
- "Could not determine server version. Compatibility check skipped.",
629
- "无法获取服务端版本,已跳过兼容性检查。",
630
- )}`);
631
- }
632
- }
633
-
634
- async function runRemoteCheck(
635
- zh: boolean,
636
- existing: Record<string, unknown>,
637
- ): Promise<void> {
638
- const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
639
- const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
640
- console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
641
- const health = await checkServiceHealth(baseUrl, apiKey);
642
- if (health.ok) {
643
- const ver = health.version ? ` (version: ${health.version})` : "";
644
- console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
645
- printCompatibilityWarning(zh, health);
646
- } else {
647
- console.log(` ✗ ${tr(zh, `Connection failed: ${health.error}`, `连接失败: ${health.error}`)}`);
648
- }
649
- console.log("");
650
- }
651
-
652
- async function setupNonInteractive(
653
- configPath: string,
654
- params: { baseUrl: string; apiKey?: string; agentPrefix?: string; accountId?: string; userId?: string; allowOffline?: boolean; forceSlot?: boolean },
655
- ): Promise<SetupResult> {
656
- try {
657
- const { baseUrl, apiKey, agentPrefix, accountId, userId, allowOffline, forceSlot } = params;
658
-
659
- // Phase 1: validate connectivity and key type BEFORE writing config
660
- const health = await checkServiceHealth(baseUrl, apiKey);
661
-
662
- if (!health.ok && !allowOffline) {
663
- return {
664
- success: false,
665
- action: "error",
666
- config: { mode: "remote", baseUrl },
667
- health,
668
- slot: { activated: false, replaced: false },
669
- error: `Server unreachable: ${health.error}. Use --allow-offline to save config anyway.`,
670
- };
671
- }
672
-
673
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
674
-
675
- if (keyProbe?.keyType === "root_key" && (!accountId || !userId)) {
676
- const missing: string[] = [];
677
- if (!accountId) missing.push("--account-id");
678
- if (!userId) missing.push("--user-id");
679
- return {
680
- success: false,
681
- action: "error",
682
- config: {
683
- mode: "remote",
684
- baseUrl,
685
- ...(apiKey ? { apiKey: maskKey(apiKey) } : {}),
686
- },
687
- health,
688
- keyProbe,
689
- slot: { activated: false, replaced: false },
690
- error: `Root API key detected. Missing: ${missing.join(", ")}. Re-run with: ${missing.map(f => `${f} <value>`).join(" ")}`,
691
- };
692
- }
693
-
694
- // Phase 2: all checks passed (or --allow-offline), write config and activate slot
695
- const pluginCfg: Record<string, unknown> = { mode: "remote", baseUrl };
696
- if (apiKey) pluginCfg.apiKey = apiKey;
697
- if (agentPrefix) pluginCfg.agent_prefix = agentPrefix;
698
- if (accountId) pluginCfg.accountId = accountId;
699
- if (userId) pluginCfg.userId = userId;
700
-
701
- writeConfig(configPath, pluginCfg);
702
- const slot = activateContextEngineSlot(configPath, !!forceSlot);
703
-
704
- if (!slot.activated && slot.previousOwner) {
705
- return {
706
- success: false,
707
- action: "slot_blocked",
708
- config: {
709
- mode: "remote",
710
- baseUrl,
711
- ...(apiKey ? { apiKey: maskKey(apiKey) } : {}),
712
- ...(agentPrefix ? { agent_prefix: agentPrefix } : {}),
713
- ...(accountId ? { accountId } : {}),
714
- ...(userId ? { userId } : {}),
715
- },
716
- health,
717
- keyProbe,
718
- slot,
719
- error: `contextEngine slot is owned by "${slot.previousOwner}". Config was saved but slot was NOT changed. Use --force-slot to replace.`,
720
- };
721
- }
722
-
723
- return {
724
- success: true,
725
- action: "configured",
726
- config: {
727
- mode: "remote",
728
- baseUrl,
729
- ...(apiKey ? { apiKey: maskKey(apiKey) } : {}),
730
- ...(agentPrefix ? { agent_prefix: agentPrefix } : {}),
731
- ...(accountId ? { accountId } : {}),
732
- ...(userId ? { userId } : {}),
733
- },
734
- health,
735
- keyProbe,
736
- slot,
737
- };
738
- } catch (err) {
739
- return {
740
- success: false,
741
- action: "error",
742
- slot: { activated: false, replaced: false },
743
- error: String(err instanceof Error ? err.message : err),
744
- };
745
- }
746
- }
747
-
748
- function printSetupResult(zh: boolean, result: SetupResult): void {
749
- console.log("");
750
- if (result.success) {
751
- console.log(`🦣 ${tr(zh, "OpenViking Plugin Setup Complete", "OpenViking 插件配置完成")}`);
752
- console.log("");
753
- if (result.config) {
754
- console.log(` mode: ${result.config.mode}`);
755
- console.log(` baseUrl: ${result.config.baseUrl}`);
756
- if (result.config.apiKey) console.log(` apiKey: ${result.config.apiKey}`);
757
- if (result.config.agent_prefix) console.log(` agent_prefix: ${result.config.agent_prefix}`);
758
- if (result.config.accountId) console.log(` accountId: ${result.config.accountId}`);
759
- if (result.config.userId) console.log(` userId: ${result.config.userId}`);
760
- }
761
- console.log("");
762
- if (result.health?.ok) {
763
- const ver = result.health.version ? ` (version: ${result.health.version})` : "";
764
- console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
765
- printCompatibilityWarning(zh, result.health);
766
- } else if (result.health) {
767
- console.log(` ✗ ${tr(zh, `Connection failed: ${result.health.error}`, `连接失败: ${result.health.error}`)}`);
768
- }
769
- if (result.keyProbe) {
770
- printKeyProbeWarning(zh, result.keyProbe);
771
- }
772
- printSlotResult(zh, result.slot);
773
- console.log("");
774
- console.log(tr(zh,
775
- "Run `openclaw gateway --force` to activate the plugin.",
776
- "运行 `openclaw gateway --force` 以激活插件。",
777
- ));
778
- } else {
779
- console.log(`✗ ${tr(zh, "Setup failed", "配置失败")}: ${result.error}`);
780
- if (result.keyProbe?.keyType === "root_key") {
781
- printKeyProbeWarning(zh, result.keyProbe);
782
- }
783
- }
784
- console.log("");
785
- }
786
-
787
- async function getStatus(configPath: string): Promise<StatusResult> {
788
- const config = readOpenClawConfig(configPath);
789
- const existing = getExistingPluginConfig(config);
790
- const slotActive = isContextEngineSlotActive(configPath);
791
-
792
- if (!existing) {
793
- return { configured: false, slotActive };
794
- }
795
-
796
- const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
797
- const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
798
- const health = await checkServiceHealth(baseUrl, apiKey);
799
- const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
800
-
801
- const agentPrefix = existing.agent_prefix ?? existing.agentId;
802
- return {
803
- configured: true,
804
- config: {
805
- mode: String(existing.mode ?? "remote"),
806
- baseUrl,
807
- hasApiKey: !!existing.apiKey,
808
- ...(agentPrefix ? { agent_prefix: String(agentPrefix) } : {}),
809
- hasAccountId: !!existing.accountId,
810
- hasUserId: !!existing.userId,
811
- },
812
- health,
813
- keyProbe,
814
- slotActive,
815
- };
816
- }
817
-
818
- function printSlotResult(zh: boolean, slot: SlotActivationResult): void {
819
- if (slot.activated && slot.replaced) {
820
- console.log(` ⚠ ${tr(zh,
821
- `Replaced context-engine slot: ${slot.previousOwner} → openviking`,
822
- `已替换 context-engine 插槽: ${slot.previousOwner} → openviking`,
823
- )}`);
824
- } else if (slot.activated) {
825
- console.log(` ✓ ${tr(zh, "Activated context-engine slot: openviking", "已激活 context-engine 插槽: openviking")}`);
826
- } else if (slot.previousOwner && slot.previousOwner !== "openviking") {
827
- console.log(` ⚠ ${tr(zh,
828
- `Context-engine slot is owned by "${slot.previousOwner}". Run: openclaw config set plugins.slots.contextEngine openviking`,
829
- `context-engine 插槽当前由 "${slot.previousOwner}" 占用。运行: openclaw config set plugins.slots.contextEngine openviking`,
830
- )}`);
831
- }
832
- }
833
-
834
- function printKeyProbeWarning(zh: boolean, probe: ApiKeyProbeResult): void {
835
- if (probe.keyType === "root_key") {
836
- console.log(` ⚠ ${tr(zh,
837
- "Root API key detected. accountId and userId are required for this key type.",
838
- "检测到 Root API Key,此类型密钥需要提供 accountId 和 userId。",
839
- )}`);
840
- if (probe.needsAccountId) {
841
- console.log(` ${tr(zh,
842
- "→ Missing: accountId (use --account-id or set in config)",
843
- "→ 缺少: accountId(使用 --account-id 或在配置中设置)",
844
- )}`);
845
- }
846
- if (probe.needsUserId) {
847
- console.log(` ${tr(zh,
848
- "→ Missing: userId (use --user-id or set in config)",
849
- "→ 缺少: userId(使用 --user-id 或在配置中设置)",
850
- )}`);
851
- }
852
- }
853
- }
854
-
855
- function printStatus(zh: boolean, result: StatusResult): void {
856
- console.log("");
857
- console.log(`🦣 ${tr(zh, "OpenViking Plugin Status", "OpenViking 插件状态")}`);
858
- console.log("");
859
-
860
- if (!result.configured) {
861
- console.log(` ${tr(zh, "Status: Not configured", "状态: 未配置")}`);
862
- console.log(` ${tr(zh, "Run `openclaw openviking setup` to configure.", "运行 `openclaw openviking setup` 进行配置。")}`);
863
- console.log("");
864
- return;
865
- }
866
-
867
- console.log(` ${tr(zh, "Status: Configured", "状态: 已配置")}`);
868
- if (result.config) {
869
- console.log(` mode: ${result.config.mode}`);
870
- console.log(` baseUrl: ${result.config.baseUrl}`);
871
- console.log(` apiKey: ${result.config.hasApiKey ? "set" : "not set"}`);
872
- if (result.config.agent_prefix) console.log(` agent_prefix: ${result.config.agent_prefix}`);
873
- console.log(` accountId: ${result.config.hasAccountId ? "set" : "not set"}`);
874
- console.log(` userId: ${result.config.hasUserId ? "set" : "not set"}`);
875
- }
876
- console.log(` slot: ${result.slotActive ? "active" : "inactive"}`);
877
- console.log("");
878
-
879
- if (result.health?.ok) {
880
- const ver = result.health.version ? ` (version: ${result.health.version})` : "";
881
- console.log(` ✓ ${tr(zh, `Server reachable${ver}`, `服务器可达${ver}`)}`);
882
- printCompatibilityWarning(zh, result.health);
883
- } else if (result.health) {
884
- console.log(` ✗ ${tr(zh, `Server unreachable: ${result.health.error}`, `服务器不可达: ${result.health.error}`)}`);
885
- }
886
-
887
- if (result.keyProbe) {
888
- printKeyProbeWarning(zh, result.keyProbe);
889
- }
890
- console.log("");
891
- }
892
-
893
- async function setupRemote(
894
- zh: boolean,
895
- configPath: string,
896
- existing: Record<string, unknown> | null,
897
- q: (prompt: string, def?: string) => Promise<string>,
898
- ): Promise<void> {
899
- console.log("");
900
- console.log(tr(zh, "── Remote Mode Configuration ──", "── 远程模式配置 ──"));
901
- console.log("");
902
-
903
- const defaultUrl = existing?.baseUrl && String(existing.baseUrl).trim()
904
- ? String(existing.baseUrl)
905
- : DEFAULT_REMOTE_URL;
906
- const defaultApiKey = existing?.apiKey ? String(existing.apiKey) : "";
907
- const defaultAgentPrefix = existing?.agent_prefix ? String(existing.agent_prefix) : "";
908
-
909
- const baseUrl = await q(tr(zh, "OpenViking server URL", "OpenViking 服务器地址"), defaultUrl);
910
- const apiKey = await q(tr(zh, "API Key (optional)", "API Key(可选)"), defaultApiKey);
911
-
912
- let accountId = existing?.accountId ? String(existing.accountId) : "";
913
- let userId = existing?.userId ? String(existing.userId) : "";
914
-
915
- if (apiKey) {
916
- console.log(tr(zh, " Detecting API key type...", " 正在检测 API Key 类型..."));
917
- const probe = await probeApiKeyType(baseUrl, apiKey);
918
- if (probe.keyType === "root_key") {
919
- console.log(tr(zh,
920
- " ⚠ Root API key detected. accountId and userId are required.",
921
- " ⚠ 检测到 Root API Key,需要提供 accountId 和 userId。",
922
- ));
923
- accountId = await q(tr(zh, "Account ID (required for root key)", "Account ID(root key 必填)"), accountId);
924
- userId = await q(tr(zh, "User ID (required for root key)", "User ID(root key 必填)"), userId);
925
- } else if (probe.keyType === "user_key") {
926
- console.log(tr(zh, " ✓ User key verified", " ✓ User key 已验证"));
927
- }
928
- }
929
-
930
- const agentPrefix = await askAgentPrefix(zh, q, defaultAgentPrefix);
931
-
932
- console.log("");
933
-
934
- console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
935
- const health = await checkServiceHealth(baseUrl, apiKey || undefined);
936
- if (health.ok) {
937
- const ver = health.version ? ` (version: ${health.version})` : "";
938
- console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
939
- printCompatibilityWarning(zh, health);
940
- } else {
941
- console.log(` ✗ ${tr(zh, `Connection failed: ${health.error}`, `连接失败: ${health.error}`)}`);
942
- console.log("");
943
- console.log(tr(zh,
944
- " The configuration will still be saved. Make sure the server is reachable\n before starting the gateway.",
945
- " 配置仍会保存。请确保服务器在启动 gateway 前可达。",
946
- ));
947
- }
948
- console.log("");
949
-
950
- const pluginCfg: Record<string, unknown> = {
951
- ...(existing ?? {}),
952
- mode: "remote",
953
- baseUrl,
954
- };
955
- if (apiKey) pluginCfg.apiKey = apiKey;
956
- else delete pluginCfg.apiKey;
957
- if (agentPrefix) pluginCfg.agent_prefix = agentPrefix;
958
- else delete pluginCfg.agent_prefix;
959
- if (accountId) pluginCfg.accountId = accountId;
960
- else delete pluginCfg.accountId;
961
- if (userId) pluginCfg.userId = userId;
962
- else delete pluginCfg.userId;
963
- delete pluginCfg.configPath;
964
- delete pluginCfg.port;
965
-
966
- writeConfig(configPath, pluginCfg);
967
-
968
- const slotResult = activateContextEngineSlot(configPath);
969
-
970
- console.log("");
971
- console.log(` ${tr(zh, "mode:", "模式:")} remote`);
972
- console.log(` baseUrl: ${baseUrl}`);
973
- if (apiKey) console.log(` apiKey: ${maskKey(apiKey)}`);
974
- if (agentPrefix) console.log(` agent_prefix: ${agentPrefix}`);
975
- if (accountId) console.log(` accountId: ${accountId}`);
976
- if (userId) console.log(` userId: ${userId}`);
977
- printSlotResult(zh, slotResult);
978
- console.log("");
979
- console.log(tr(zh,
980
- "Run `openclaw gateway --force` to activate the plugin.",
981
- "运行 `openclaw gateway --force` 以激活插件。",
982
- ));
983
- console.log("");
984
- }
985
-
986
- export const __test__ = {
987
- isLegacyLocalMode,
988
- isValidAgentPrefixInput,
989
- activateContextEngineSlot,
990
- isContextEngineSlotActive,
991
- getStatus,
992
- setupNonInteractive,
993
- checkVersionCompatibility,
994
- parseVersionTuple,
995
- compareVersions,
996
- probeApiKeyType,
997
- ensureInstallRecord,
998
- findPluginPackageRoot,
999
- readCompatRangeFromManifest,
1000
- readPluginVersion,
1001
- setExitCodeOnFailure,
1002
- };
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import * as readline from "node:readline";
5
+ import { fileURLToPath } from "node:url";
6
+ import { getEnv } from "../runtime-utils.js";
7
+
8
+ const HOME = os.homedir();
9
+ const OPENCLAW_DIR = getEnv("OPENCLAW_STATE_DIR") || path.join(HOME, ".openclaw");
10
+ const DEFAULT_REMOTE_URL = "http://127.0.0.1:1933";
11
+
12
+ function findPluginPackageRoot(fromDir = path.dirname(fileURLToPath(import.meta.url))): string | null {
13
+ let current = path.resolve(fromDir);
14
+ for (let depth = 0; depth < 5; depth += 1) {
15
+ if (
16
+ fs.existsSync(path.join(current, "package.json")) &&
17
+ fs.existsSync(path.join(current, "openclaw.plugin.json"))
18
+ ) {
19
+ return current;
20
+ }
21
+
22
+ const parent = path.dirname(current);
23
+ if (parent === current) break;
24
+ current = parent;
25
+ }
26
+ return null;
27
+ }
28
+
29
+ function readPluginVersion(): string {
30
+ try {
31
+ const packageRoot = findPluginPackageRoot();
32
+ if (!packageRoot) return "unknown";
33
+ const pkgPath = path.join(packageRoot, "package.json");
34
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
35
+ return String(pkg.version ?? "unknown");
36
+ } catch {
37
+ return "unknown";
38
+ }
39
+ }
40
+
41
+ function readCompatRangeFromManifest(): { min: string; max: string } {
42
+ try {
43
+ const packageRoot = findPluginPackageRoot();
44
+ if (!packageRoot) return { min: "", max: "" };
45
+ const manifestPath = path.join(packageRoot, "install-manifest.json");
46
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
47
+ const compat = manifest?.compatibility ?? {};
48
+ return {
49
+ min: String(compat.minOpenvikingVersion ?? ""),
50
+ max: String(compat.maxOpenvikingVersion ?? ""),
51
+ };
52
+ } catch {
53
+ return { min: "", max: "" };
54
+ }
55
+ }
56
+
57
+ const PLUGIN_VERSION = readPluginVersion();
58
+ const { min: COMPATIBLE_SERVER_MIN, max: COMPATIBLE_SERVER_MAX } = readCompatRangeFromManifest();
59
+
60
+ type CommandProgram = {
61
+ command: (name: string) => CommandBuilder;
62
+ };
63
+
64
+ type CommandBuilder = {
65
+ description: (desc: string) => CommandBuilder;
66
+ option: (flags: string, desc: string) => CommandBuilder;
67
+ command: (name: string) => CommandBuilder;
68
+ action: (fn: (...args: unknown[]) => void | Promise<void>) => CommandBuilder;
69
+ };
70
+
71
+ type RegisterCliArgs = {
72
+ program: CommandProgram;
73
+ };
74
+
75
+ function tr(langZh: boolean, en: string, zh: string): string {
76
+ return langZh ? zh : en;
77
+ }
78
+
79
+ function maskKey(key: string): string {
80
+ if (key.length <= 8) return "****";
81
+ return `${key.slice(0, 4)}...${key.slice(-4)}`;
82
+ }
83
+
84
+ function isValidAgentPrefixInput(value: string): boolean {
85
+ const trimmed = value.trim();
86
+ return !trimmed || /^[a-zA-Z0-9_-]+$/.test(trimmed);
87
+ }
88
+
89
+ async function askAgentPrefix(
90
+ zh: boolean,
91
+ q: (prompt: string, def?: string) => Promise<string>,
92
+ defaultValue: string,
93
+ ): Promise<string> {
94
+ while (true) {
95
+ const value = (await q(
96
+ tr(zh, "Agent Prefix (optional)", "Agent Prefix(可选)"),
97
+ defaultValue,
98
+ )).trim();
99
+ if (isValidAgentPrefixInput(value)) {
100
+ return value;
101
+ }
102
+ console.log(
103
+ ` ✗ ${tr(
104
+ zh,
105
+ "Agent Prefix may only contain letters, digits, underscores, and hyphens, or be empty.",
106
+ "Agent Prefix 只能包含字母、数字、下划线和连字符,或留空。",
107
+ )}`,
108
+ );
109
+ }
110
+ }
111
+
112
+ function ask(rl: readline.Interface, prompt: string, defaultValue = ""): Promise<string> {
113
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
114
+ return new Promise((resolve) => {
115
+ rl.question(`${prompt}${suffix}: `, (answer) => {
116
+ resolve((answer ?? "").trim() || defaultValue);
117
+ });
118
+ });
119
+ }
120
+
121
+ type VersionCompatibility = "compatible" | "server_too_old" | "server_too_new" | "unknown";
122
+
123
+ type HealthResult = {
124
+ ok: boolean;
125
+ version: string;
126
+ error: string;
127
+ compatibility: VersionCompatibility;
128
+ pluginVersion: string;
129
+ compatRange: string;
130
+ };
131
+
132
+ function parseVersionTuple(v: string): number[] | null {
133
+ const cleaned = v.replace(/^v/i, "").split("-")[0];
134
+ const parts = cleaned.split(".").map(Number);
135
+ if (parts.some(isNaN)) return null;
136
+ return parts;
137
+ }
138
+
139
+ function compareVersions(a: number[], b: number[]): number {
140
+ const len = Math.max(a.length, b.length);
141
+ for (let i = 0; i < len; i++) {
142
+ const diff = (a[i] ?? 0) - (b[i] ?? 0);
143
+ if (diff !== 0) return diff;
144
+ }
145
+ return 0;
146
+ }
147
+
148
+ function checkVersionCompatibility(serverVersion: string): VersionCompatibility {
149
+ if (!serverVersion) return "unknown";
150
+ const sv = parseVersionTuple(serverVersion);
151
+ if (!sv) return "unknown";
152
+
153
+ if (COMPATIBLE_SERVER_MIN) {
154
+ const minV = parseVersionTuple(COMPATIBLE_SERVER_MIN);
155
+ if (minV && compareVersions(sv, minV) < 0) return "server_too_old";
156
+ }
157
+ if (COMPATIBLE_SERVER_MAX) {
158
+ const maxV = parseVersionTuple(COMPATIBLE_SERVER_MAX);
159
+ if (maxV && compareVersions(sv, maxV) > 0) return "server_too_new";
160
+ }
161
+ return "compatible";
162
+ }
163
+
164
+ function formatCompatRange(): string {
165
+ if (COMPATIBLE_SERVER_MIN && COMPATIBLE_SERVER_MAX) return `${COMPATIBLE_SERVER_MIN} ~ ${COMPATIBLE_SERVER_MAX}`;
166
+ if (COMPATIBLE_SERVER_MIN) return `>= ${COMPATIBLE_SERVER_MIN}`;
167
+ if (COMPATIBLE_SERVER_MAX) return `<= ${COMPATIBLE_SERVER_MAX}`;
168
+ return "any";
169
+ }
170
+
171
+ type ApiKeyProbeResult = {
172
+ keyType: "user_key" | "root_key" | "no_key" | "unknown";
173
+ needsAccountId: boolean;
174
+ needsUserId: boolean;
175
+ detail: string;
176
+ };
177
+
178
+ async function probeApiKeyType(baseUrl: string, apiKey?: string): Promise<ApiKeyProbeResult> {
179
+ if (!apiKey) return { keyType: "no_key", needsAccountId: false, needsUserId: false, detail: "No API key configured" };
180
+
181
+ const controller = new AbortController();
182
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
183
+ const sessionsUrl = `${baseUrl.replace(/\/+$/, "")}/api/v1/sessions?limit=1`;
184
+ try {
185
+ const headers: Record<string, string> = { "X-API-Key": apiKey };
186
+ const response = await fetch(sessionsUrl, {
187
+ headers,
188
+ signal: controller.signal,
189
+ });
190
+
191
+ if (response.ok) {
192
+ return { keyType: "user_key", needsAccountId: false, needsUserId: false, detail: "API key has full user context" };
193
+ }
194
+
195
+ // Server raises InvalidArgumentError (-> HTTP 400) when a ROOT key calls
196
+ // tenant-scoped endpoints without X-OpenViking-Account / X-OpenViking-User.
197
+ // 401/403 may also be returned by older versions, FastAPI validation can
198
+ // surface as 422. Treat all four as candidates for tenant-context errors.
199
+ if ([400, 401, 403, 422].includes(response.status)) {
200
+ let body = "";
201
+ try {
202
+ body = await response.text();
203
+ } catch { /* ignore parse errors */ }
204
+ const lower = body.toLowerCase();
205
+ const needsAccount = /x-openviking-account|account[_ ]?id|account context|tenant/.test(lower);
206
+ const needsUser = /x-openviking-user|user[_ ]?id|user context|user key/.test(lower);
207
+ if (needsAccount || needsUser) {
208
+ return {
209
+ keyType: "root_key",
210
+ needsAccountId: needsAccount,
211
+ needsUserId: needsUser,
212
+ detail: body.slice(0, 200),
213
+ };
214
+ }
215
+
216
+ // Body did not name account/user/tenant explicitly (custom auth middleware,
217
+ // localized message, etc.). Re-probe with placeholder tenant headers; if
218
+ // the failure was due to missing tenant headers the response will change.
219
+ try {
220
+ const probeHeaders: Record<string, string> = {
221
+ "X-API-Key": apiKey,
222
+ "X-OpenViking-Account": "__probe__",
223
+ "X-OpenViking-User": "__probe__",
224
+ };
225
+ const probe2 = await fetch(sessionsUrl, {
226
+ headers: probeHeaders,
227
+ signal: controller.signal,
228
+ });
229
+ if (probe2.status !== response.status) {
230
+ return {
231
+ keyType: "root_key",
232
+ needsAccountId: true,
233
+ needsUserId: true,
234
+ detail: body.slice(0, 200) || `HTTP ${response.status} -> ${probe2.status} after adding tenant headers`,
235
+ };
236
+ }
237
+ } catch { /* ignore probe errors, fall through to unknown */ }
238
+
239
+ if (response.status === 401 || response.status === 403) {
240
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status} - authentication failed, verify your API key` };
241
+ }
242
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}${body ? ` - ${body.slice(0, 160)}` : ""}` };
243
+ }
244
+
245
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: `HTTP ${response.status}` };
246
+ } catch (err) {
247
+ return { keyType: "unknown", needsAccountId: false, needsUserId: false, detail: String(err instanceof Error ? err.message : err) };
248
+ } finally {
249
+ clearTimeout(timeoutId);
250
+ }
251
+ }
252
+
253
+ async function checkServiceHealth(baseUrl: string, apiKey?: string): Promise<HealthResult> {
254
+ const controller = new AbortController();
255
+ const timeoutId = setTimeout(() => controller.abort(), 10_000);
256
+ try {
257
+ const headers: Record<string, string> = {};
258
+ if (apiKey) headers["X-API-Key"] = apiKey;
259
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}/health`, {
260
+ headers,
261
+ signal: controller.signal,
262
+ });
263
+ if (response.ok) {
264
+ try {
265
+ const data = await response.json() as Record<string, unknown>;
266
+ const result = (data.result ?? data) as Record<string, unknown>;
267
+ const version = String(result.version ?? data.version ?? "");
268
+ const compatibility = checkVersionCompatibility(version);
269
+ return { ok: true, version, error: "", compatibility, pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
270
+ } catch {
271
+ return { ok: true, version: "", error: "", compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
272
+ }
273
+ }
274
+ return { ok: false, version: "", error: `HTTP ${response.status}`, compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
275
+ } catch (err) {
276
+ return { ok: false, version: "", error: String(err instanceof Error ? err.message : err), compatibility: "unknown", pluginVersion: PLUGIN_VERSION, compatRange: formatCompatRange() };
277
+ } finally {
278
+ clearTimeout(timeoutId);
279
+ }
280
+ }
281
+
282
+ function readOpenClawConfig(configPath: string): Record<string, unknown> {
283
+ if (!fs.existsSync(configPath)) return {};
284
+ try {
285
+ return JSON.parse(fs.readFileSync(configPath, "utf-8"));
286
+ } catch {
287
+ return {};
288
+ }
289
+ }
290
+
291
+ function getExistingPluginConfig(config: Record<string, unknown>): Record<string, unknown> | null {
292
+ const plugins = config.plugins as Record<string, unknown> | undefined;
293
+ if (!plugins) return null;
294
+ const entries = plugins.entries as Record<string, unknown> | undefined;
295
+ if (!entries) return null;
296
+ const entry = entries.openviking as Record<string, unknown> | undefined;
297
+ if (!entry) return null;
298
+ const cfg = entry.config as Record<string, unknown> | undefined;
299
+ return cfg && cfg.mode ? cfg : null;
300
+ }
301
+
302
+ function backupConfig(configPath: string): string | null {
303
+ if (!fs.existsSync(configPath)) return null;
304
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
305
+ const backupPath = `${configPath}.bak.${timestamp}`;
306
+ try {
307
+ fs.copyFileSync(configPath, backupPath);
308
+ return backupPath;
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ function ensureInstallRecord(plugins: Record<string, unknown>): void {
315
+ const installs = plugins.installs as Record<string, unknown> | undefined;
316
+ if (installs && typeof installs === "object") {
317
+ delete installs.openviking;
318
+ }
319
+
320
+ if (!plugins.allow) plugins.allow = [];
321
+ const allow = plugins.allow as string[];
322
+ if (!allow.includes("openviking")) {
323
+ allow.push("openviking");
324
+ }
325
+ }
326
+
327
+ function writeConfig(
328
+ configPath: string,
329
+ pluginCfg: Record<string, unknown>,
330
+ ): void {
331
+ const configDir = path.dirname(configPath);
332
+ if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
333
+
334
+ backupConfig(configPath);
335
+
336
+ const config = readOpenClawConfig(configPath);
337
+
338
+ if (!config.plugins) config.plugins = {};
339
+ const plugins = config.plugins as Record<string, unknown>;
340
+ if (!plugins.entries) plugins.entries = {};
341
+ const entries = plugins.entries as Record<string, unknown>;
342
+
343
+ const existingEntry = (entries.openviking as Record<string, unknown>) ?? {};
344
+ entries.openviking = { ...existingEntry, config: pluginCfg };
345
+
346
+ ensureInstallRecord(plugins);
347
+
348
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
349
+ }
350
+
351
+ function detectLangZh(options: Record<string, unknown>): boolean {
352
+ if (options.zh) return true;
353
+ const lang = getEnv("LANG") || getEnv("LC_ALL") || "";
354
+ return /^zh/i.test(lang);
355
+ }
356
+
357
+ function isLegacyLocalMode(existing: Record<string, unknown>): boolean {
358
+ const mode = existing.mode;
359
+ return mode !== "remote";
360
+ }
361
+
362
+ type SetupResult = {
363
+ success: boolean;
364
+ action: "configured" | "existing" | "error" | "slot_blocked";
365
+ config?: {
366
+ mode: string;
367
+ baseUrl: string;
368
+ apiKey?: string;
369
+ agent_prefix?: string;
370
+ accountId?: string;
371
+ userId?: string;
372
+ };
373
+ health?: HealthResult;
374
+ keyProbe?: ApiKeyProbeResult;
375
+ slot: SlotActivationResult;
376
+ error?: string;
377
+ };
378
+
379
+ function setExitCodeOnFailure(result: { success: boolean }): void {
380
+ if (!result.success) {
381
+ process.exitCode = 1;
382
+ }
383
+ }
384
+
385
+ type StatusResult = {
386
+ configured: boolean;
387
+ config?: {
388
+ mode: string;
389
+ baseUrl: string;
390
+ hasApiKey: boolean;
391
+ agent_prefix?: string;
392
+ hasAccountId: boolean;
393
+ hasUserId: boolean;
394
+ };
395
+ health?: HealthResult;
396
+ keyProbe?: ApiKeyProbeResult;
397
+ slotActive: boolean;
398
+ };
399
+
400
+ type SlotActivationResult = {
401
+ activated: boolean;
402
+ previousOwner?: string;
403
+ replaced: boolean;
404
+ };
405
+
406
+ function activateContextEngineSlot(configPath: string, force = false): SlotActivationResult {
407
+ const config = readOpenClawConfig(configPath);
408
+ if (!config.plugins) config.plugins = {};
409
+ const plugins = config.plugins as Record<string, unknown>;
410
+ if (!plugins.slots) plugins.slots = {};
411
+ const slots = plugins.slots as Record<string, unknown>;
412
+
413
+ const current = slots.contextEngine as string | undefined;
414
+
415
+ if (current === "openviking") return { activated: false, replaced: false };
416
+
417
+ if (current && current !== "openviking" && !force) {
418
+ return { activated: false, previousOwner: current, replaced: false };
419
+ }
420
+
421
+ slots.contextEngine = "openviking";
422
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
423
+ return { activated: true, previousOwner: current || undefined, replaced: !!current };
424
+ }
425
+
426
+ function isContextEngineSlotActive(configPath: string): boolean {
427
+ const config = readOpenClawConfig(configPath);
428
+ const plugins = config.plugins as Record<string, unknown> | undefined;
429
+ if (!plugins) return false;
430
+ const slots = plugins.slots as Record<string, unknown> | undefined;
431
+ return slots?.contextEngine === "openviking";
432
+ }
433
+
434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
+ export function registerSetupCli(api: any): void {
436
+ if (!api.registerCli) {
437
+ api.logger.info("openviking: registerCli not available, setup command skipped");
438
+ return;
439
+ }
440
+
441
+ api.registerCli(
442
+ ({ program }: RegisterCliArgs) => {
443
+ const ovCmd = program.command("openviking").description("OpenViking plugin commands");
444
+
445
+ ovCmd
446
+ .command("setup")
447
+ .description("Setup OpenViking plugin (supports both interactive and non-interactive modes)")
448
+ .option("--reconfigure", "Force re-entry of all configuration values")
449
+ .option("--zh", "Chinese prompts")
450
+ .option("--base-url <url>", "OpenViking server URL (enables non-interactive mode)")
451
+ .option("--api-key <key>", "API key for authentication")
452
+ .option("--agent-prefix <prefix>", "Agent routing prefix for namespace isolation")
453
+ .option("--account-id <id>", "Account ID (required for root API keys)")
454
+ .option("--user-id <id>", "User ID (required for root API keys)")
455
+ .option("--allow-offline", "Allow config write even if server is unreachable")
456
+ .option("--force-slot", "Replace existing contextEngine slot even if owned by another plugin")
457
+ .option("--json", "Output result as JSON (machine-readable)")
458
+ .action(async (...args: unknown[]) => {
459
+ const options = (args[0] ?? {}) as Record<string, unknown>;
460
+ const {
461
+ reconfigure, zh: zhOpt, baseUrl, apiKey, agentPrefix,
462
+ accountId, userId, allowOffline, forceSlot, json: jsonOpt,
463
+ } = options as {
464
+ reconfigure?: boolean; zh?: boolean; baseUrl?: string;
465
+ apiKey?: string; agentPrefix?: string; accountId?: string;
466
+ userId?: string; allowOffline?: boolean; forceSlot?: boolean;
467
+ json?: boolean;
468
+ };
469
+ const zh = detectLangZh(options);
470
+ const configPath = path.join(OPENCLAW_DIR, "openclaw.json");
471
+ const jsonMode = !!jsonOpt;
472
+ const nonInteractive = !!baseUrl;
473
+
474
+ if (nonInteractive) {
475
+ const result = await setupNonInteractive(configPath, {
476
+ baseUrl: baseUrl!,
477
+ apiKey,
478
+ agentPrefix,
479
+ accountId,
480
+ userId,
481
+ allowOffline: !!allowOffline,
482
+ forceSlot: !!forceSlot,
483
+ });
484
+ if (jsonMode) {
485
+ console.log(JSON.stringify(result, null, 2));
486
+ } else {
487
+ printSetupResult(zh, result);
488
+ }
489
+ setExitCodeOnFailure(result);
490
+ return;
491
+ }
492
+
493
+ if (jsonMode && !nonInteractive) {
494
+ const result: SetupResult = {
495
+ success: false,
496
+ action: "error",
497
+ slot: { activated: false, replaced: false },
498
+ error: "--json requires --base-url for non-interactive mode",
499
+ };
500
+ console.log(JSON.stringify(result, null, 2));
501
+ setExitCodeOnFailure(result);
502
+ return;
503
+ }
504
+
505
+ console.log("");
506
+ console.log(`🦣 ${tr(zh, "OpenViking Plugin Setup", "OpenViking 插件配置向导")}`);
507
+ console.log("");
508
+
509
+ const config = readOpenClawConfig(configPath);
510
+ const existing = getExistingPluginConfig(config);
511
+
512
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
513
+ const q = (prompt: string, def = "") => ask(rl, prompt, def);
514
+
515
+ try {
516
+ if (existing && !reconfigure) {
517
+ if (isLegacyLocalMode(existing)) {
518
+ console.log(tr(
519
+ zh,
520
+ "Existing configuration uses local mode, which is no longer supported.",
521
+ "当前配置为本地模式,已不再支持。",
522
+ ));
523
+ console.log(tr(
524
+ zh,
525
+ "Run `openclaw openviking setup --reconfigure` to configure a remote OpenViking server.",
526
+ "请运行 `openclaw openviking setup --reconfigure` 以配置远程 OpenViking 服务。",
527
+ ));
528
+ console.log("");
529
+ return;
530
+ }
531
+
532
+ console.log(tr(zh, "Existing configuration found:", "已找到现有配置:"));
533
+ console.log(` mode: ${existing.mode}`);
534
+ console.log(` baseUrl: ${existing.baseUrl ?? DEFAULT_REMOTE_URL}`);
535
+ if (existing.apiKey) console.log(` apiKey: ${maskKey(String(existing.apiKey))}`);
536
+ if (existing.agent_prefix) console.log(` agent_prefix: ${existing.agent_prefix}`);
537
+ console.log("");
538
+ console.log(tr(
539
+ zh,
540
+ "Press Enter to keep existing values, or use --reconfigure to change.",
541
+ "按 Enter 保留现有配置,或使用 --reconfigure 重新配置。",
542
+ ));
543
+ console.log("");
544
+ console.log(tr(zh, "✓ Using existing configuration", "✓ 使用现有配置"));
545
+ console.log("");
546
+
547
+ await runRemoteCheck(zh, existing);
548
+
549
+ const slotResult = activateContextEngineSlot(configPath);
550
+ printSlotResult(zh, slotResult);
551
+
552
+ console.log(tr(zh,
553
+ "✓ Plugin is ready. Run `openclaw gateway --force` to activate.",
554
+ "✓ 插件已就绪。运行 `openclaw gateway --force` 以激活。",
555
+ ));
556
+ console.log("");
557
+ return;
558
+ }
559
+
560
+ if (existing && options.reconfigure) {
561
+ console.log(tr(zh, "Existing configuration found:", "已找到现有配置:"));
562
+ if (isLegacyLocalMode(existing)) {
563
+ console.log(tr(zh,
564
+ "(Previous local-mode settings will be replaced with remote settings.)",
565
+ "(将用远程模式设置替换此前的本地模式配置。)",
566
+ ));
567
+ } else {
568
+ console.log(` mode: ${existing.mode}`);
569
+ console.log(` baseUrl: ${existing.baseUrl ?? DEFAULT_REMOTE_URL}`);
570
+ if (existing.apiKey) console.log(` apiKey: ${maskKey(String(existing.apiKey))}`);
571
+ }
572
+ console.log("");
573
+ console.log(tr(zh, "Reconfiguring...", "重新配置中..."));
574
+ console.log("");
575
+ } else {
576
+ console.log(tr(zh,
577
+ "No existing configuration found. Starting setup wizard.",
578
+ "未找到现有配置,开始配置向导。",
579
+ ));
580
+ console.log("");
581
+ }
582
+
583
+ await setupRemote(zh, configPath, existing, q);
584
+ } finally {
585
+ rl.close();
586
+ }
587
+ });
588
+
589
+ ovCmd
590
+ .command("status")
591
+ .description("Show current OpenViking plugin status and connectivity")
592
+ .option("--zh", "Chinese prompts")
593
+ .option("--json", "Output result as JSON (machine-readable)")
594
+ .action(async (...args: unknown[]) => {
595
+ const options = (args[0] ?? {}) as Record<string, unknown>;
596
+ const { zh: zhOpt, json: jsonOpt } = options as { zh?: boolean; json?: boolean };
597
+ const zh = detectLangZh(options);
598
+ const configPath = path.join(OPENCLAW_DIR, "openclaw.json");
599
+ const jsonMode = !!jsonOpt;
600
+
601
+ const result = await getStatus(configPath);
602
+
603
+ if (jsonMode) {
604
+ console.log(JSON.stringify(result, null, 2));
605
+ return;
606
+ }
607
+
608
+ printStatus(zh, result);
609
+ });
610
+ },
611
+ { commands: ["openviking"] },
612
+ );
613
+ }
614
+
615
+ function printCompatibilityWarning(zh: boolean, health: HealthResult): void {
616
+ if (health.compatibility === "server_too_old") {
617
+ console.log(` ⚠ ${tr(zh,
618
+ `Server version ${health.version} is older than recommended (${health.compatRange}). Some features may not work. Please upgrade OpenViking server.`,
619
+ `服务端版本 ${health.version} 低于推荐范围(${health.compatRange})。部分功能可能不可用,请升级 OpenViking 服务端。`,
620
+ )}`);
621
+ } else if (health.compatibility === "server_too_new") {
622
+ console.log(` ⚠ ${tr(zh,
623
+ `Server version ${health.version} is newer than supported (${health.compatRange}). Please upgrade the OpenViking plugin.`,
624
+ `服务端版本 ${health.version} 高于插件支持范围(${health.compatRange})。请升级 OpenViking 插件。`,
625
+ )}`);
626
+ } else if (health.compatibility === "unknown" && health.ok) {
627
+ console.log(` ⚠ ${tr(zh,
628
+ "Could not determine server version. Compatibility check skipped.",
629
+ "无法获取服务端版本,已跳过兼容性检查。",
630
+ )}`);
631
+ }
632
+ }
633
+
634
+ async function runRemoteCheck(
635
+ zh: boolean,
636
+ existing: Record<string, unknown>,
637
+ ): Promise<void> {
638
+ const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
639
+ const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
640
+ console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
641
+ const health = await checkServiceHealth(baseUrl, apiKey);
642
+ if (health.ok) {
643
+ const ver = health.version ? ` (version: ${health.version})` : "";
644
+ console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
645
+ printCompatibilityWarning(zh, health);
646
+ } else {
647
+ console.log(` ✗ ${tr(zh, `Connection failed: ${health.error}`, `连接失败: ${health.error}`)}`);
648
+ }
649
+ console.log("");
650
+ }
651
+
652
+ async function setupNonInteractive(
653
+ configPath: string,
654
+ params: { baseUrl: string; apiKey?: string; agentPrefix?: string; accountId?: string; userId?: string; allowOffline?: boolean; forceSlot?: boolean },
655
+ ): Promise<SetupResult> {
656
+ try {
657
+ const { baseUrl, apiKey, agentPrefix, accountId, userId, allowOffline, forceSlot } = params;
658
+
659
+ // Phase 1: validate connectivity and key type BEFORE writing config
660
+ const health = await checkServiceHealth(baseUrl, apiKey);
661
+
662
+ if (!health.ok && !allowOffline) {
663
+ return {
664
+ success: false,
665
+ action: "error",
666
+ config: { mode: "remote", baseUrl },
667
+ health,
668
+ slot: { activated: false, replaced: false },
669
+ error: `Server unreachable: ${health.error}. Use --allow-offline to save config anyway.`,
670
+ };
671
+ }
672
+
673
+ const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
674
+
675
+ if (keyProbe?.keyType === "root_key" && (!accountId || !userId)) {
676
+ const missing: string[] = [];
677
+ if (!accountId) missing.push("--account-id");
678
+ if (!userId) missing.push("--user-id");
679
+ return {
680
+ success: false,
681
+ action: "error",
682
+ config: {
683
+ mode: "remote",
684
+ baseUrl,
685
+ ...(apiKey ? { apiKey: maskKey(apiKey) } : {}),
686
+ },
687
+ health,
688
+ keyProbe,
689
+ slot: { activated: false, replaced: false },
690
+ error: `Root API key detected. Missing: ${missing.join(", ")}. Re-run with: ${missing.map(f => `${f} <value>`).join(" ")}`,
691
+ };
692
+ }
693
+
694
+ // Phase 2: all checks passed (or --allow-offline), write config and activate slot
695
+ const pluginCfg: Record<string, unknown> = { mode: "remote", baseUrl };
696
+ if (apiKey) pluginCfg.apiKey = apiKey;
697
+ if (agentPrefix) pluginCfg.agent_prefix = agentPrefix;
698
+ if (accountId) pluginCfg.accountId = accountId;
699
+ if (userId) pluginCfg.userId = userId;
700
+
701
+ writeConfig(configPath, pluginCfg);
702
+ const slot = activateContextEngineSlot(configPath, !!forceSlot);
703
+
704
+ if (!slot.activated && slot.previousOwner) {
705
+ return {
706
+ success: false,
707
+ action: "slot_blocked",
708
+ config: {
709
+ mode: "remote",
710
+ baseUrl,
711
+ ...(apiKey ? { apiKey: maskKey(apiKey) } : {}),
712
+ ...(agentPrefix ? { agent_prefix: agentPrefix } : {}),
713
+ ...(accountId ? { accountId } : {}),
714
+ ...(userId ? { userId } : {}),
715
+ },
716
+ health,
717
+ keyProbe,
718
+ slot,
719
+ error: `contextEngine slot is owned by "${slot.previousOwner}". Config was saved but slot was NOT changed. Use --force-slot to replace.`,
720
+ };
721
+ }
722
+
723
+ return {
724
+ success: true,
725
+ action: "configured",
726
+ config: {
727
+ mode: "remote",
728
+ baseUrl,
729
+ ...(apiKey ? { apiKey: maskKey(apiKey) } : {}),
730
+ ...(agentPrefix ? { agent_prefix: agentPrefix } : {}),
731
+ ...(accountId ? { accountId } : {}),
732
+ ...(userId ? { userId } : {}),
733
+ },
734
+ health,
735
+ keyProbe,
736
+ slot,
737
+ };
738
+ } catch (err) {
739
+ return {
740
+ success: false,
741
+ action: "error",
742
+ slot: { activated: false, replaced: false },
743
+ error: String(err instanceof Error ? err.message : err),
744
+ };
745
+ }
746
+ }
747
+
748
+ function printSetupResult(zh: boolean, result: SetupResult): void {
749
+ console.log("");
750
+ if (result.success) {
751
+ console.log(`🦣 ${tr(zh, "OpenViking Plugin Setup Complete", "OpenViking 插件配置完成")}`);
752
+ console.log("");
753
+ if (result.config) {
754
+ console.log(` mode: ${result.config.mode}`);
755
+ console.log(` baseUrl: ${result.config.baseUrl}`);
756
+ if (result.config.apiKey) console.log(` apiKey: ${result.config.apiKey}`);
757
+ if (result.config.agent_prefix) console.log(` agent_prefix: ${result.config.agent_prefix}`);
758
+ if (result.config.accountId) console.log(` accountId: ${result.config.accountId}`);
759
+ if (result.config.userId) console.log(` userId: ${result.config.userId}`);
760
+ }
761
+ console.log("");
762
+ if (result.health?.ok) {
763
+ const ver = result.health.version ? ` (version: ${result.health.version})` : "";
764
+ console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
765
+ printCompatibilityWarning(zh, result.health);
766
+ } else if (result.health) {
767
+ console.log(` ✗ ${tr(zh, `Connection failed: ${result.health.error}`, `连接失败: ${result.health.error}`)}`);
768
+ }
769
+ if (result.keyProbe) {
770
+ printKeyProbeWarning(zh, result.keyProbe);
771
+ }
772
+ printSlotResult(zh, result.slot);
773
+ console.log("");
774
+ console.log(tr(zh,
775
+ "Run `openclaw gateway --force` to activate the plugin.",
776
+ "运行 `openclaw gateway --force` 以激活插件。",
777
+ ));
778
+ } else {
779
+ console.log(`✗ ${tr(zh, "Setup failed", "配置失败")}: ${result.error}`);
780
+ if (result.keyProbe?.keyType === "root_key") {
781
+ printKeyProbeWarning(zh, result.keyProbe);
782
+ }
783
+ }
784
+ console.log("");
785
+ }
786
+
787
+ async function getStatus(configPath: string): Promise<StatusResult> {
788
+ const config = readOpenClawConfig(configPath);
789
+ const existing = getExistingPluginConfig(config);
790
+ const slotActive = isContextEngineSlotActive(configPath);
791
+
792
+ if (!existing) {
793
+ return { configured: false, slotActive };
794
+ }
795
+
796
+ const baseUrl = String(existing.baseUrl ?? DEFAULT_REMOTE_URL);
797
+ const apiKey = existing.apiKey ? String(existing.apiKey) : undefined;
798
+ const health = await checkServiceHealth(baseUrl, apiKey);
799
+ const keyProbe = health.ok ? await probeApiKeyType(baseUrl, apiKey) : undefined;
800
+
801
+ const agentPrefix = existing.agent_prefix ?? existing.agentId;
802
+ return {
803
+ configured: true,
804
+ config: {
805
+ mode: String(existing.mode ?? "remote"),
806
+ baseUrl,
807
+ hasApiKey: !!existing.apiKey,
808
+ ...(agentPrefix ? { agent_prefix: String(agentPrefix) } : {}),
809
+ hasAccountId: !!existing.accountId,
810
+ hasUserId: !!existing.userId,
811
+ },
812
+ health,
813
+ keyProbe,
814
+ slotActive,
815
+ };
816
+ }
817
+
818
+ function printSlotResult(zh: boolean, slot: SlotActivationResult): void {
819
+ if (slot.activated && slot.replaced) {
820
+ console.log(` ⚠ ${tr(zh,
821
+ `Replaced context-engine slot: ${slot.previousOwner} → openviking`,
822
+ `已替换 context-engine 插槽: ${slot.previousOwner} → openviking`,
823
+ )}`);
824
+ } else if (slot.activated) {
825
+ console.log(` ✓ ${tr(zh, "Activated context-engine slot: openviking", "已激活 context-engine 插槽: openviking")}`);
826
+ } else if (slot.previousOwner && slot.previousOwner !== "openviking") {
827
+ console.log(` ⚠ ${tr(zh,
828
+ `Context-engine slot is owned by "${slot.previousOwner}". Run: openclaw config set plugins.slots.contextEngine openviking`,
829
+ `context-engine 插槽当前由 "${slot.previousOwner}" 占用。运行: openclaw config set plugins.slots.contextEngine openviking`,
830
+ )}`);
831
+ }
832
+ }
833
+
834
+ function printKeyProbeWarning(zh: boolean, probe: ApiKeyProbeResult): void {
835
+ if (probe.keyType === "root_key") {
836
+ console.log(` ⚠ ${tr(zh,
837
+ "Root API key detected. accountId and userId are required for this key type.",
838
+ "检测到 Root API Key,此类型密钥需要提供 accountId 和 userId。",
839
+ )}`);
840
+ if (probe.needsAccountId) {
841
+ console.log(` ${tr(zh,
842
+ "→ Missing: accountId (use --account-id or set in config)",
843
+ "→ 缺少: accountId(使用 --account-id 或在配置中设置)",
844
+ )}`);
845
+ }
846
+ if (probe.needsUserId) {
847
+ console.log(` ${tr(zh,
848
+ "→ Missing: userId (use --user-id or set in config)",
849
+ "→ 缺少: userId(使用 --user-id 或在配置中设置)",
850
+ )}`);
851
+ }
852
+ }
853
+ }
854
+
855
+ function printStatus(zh: boolean, result: StatusResult): void {
856
+ console.log("");
857
+ console.log(`🦣 ${tr(zh, "OpenViking Plugin Status", "OpenViking 插件状态")}`);
858
+ console.log("");
859
+
860
+ if (!result.configured) {
861
+ console.log(` ${tr(zh, "Status: Not configured", "状态: 未配置")}`);
862
+ console.log(` ${tr(zh, "Run `openclaw openviking setup` to configure.", "运行 `openclaw openviking setup` 进行配置。")}`);
863
+ console.log("");
864
+ return;
865
+ }
866
+
867
+ console.log(` ${tr(zh, "Status: Configured", "状态: 已配置")}`);
868
+ if (result.config) {
869
+ console.log(` mode: ${result.config.mode}`);
870
+ console.log(` baseUrl: ${result.config.baseUrl}`);
871
+ console.log(` apiKey: ${result.config.hasApiKey ? "set" : "not set"}`);
872
+ if (result.config.agent_prefix) console.log(` agent_prefix: ${result.config.agent_prefix}`);
873
+ console.log(` accountId: ${result.config.hasAccountId ? "set" : "not set"}`);
874
+ console.log(` userId: ${result.config.hasUserId ? "set" : "not set"}`);
875
+ }
876
+ console.log(` slot: ${result.slotActive ? "active" : "inactive"}`);
877
+ console.log("");
878
+
879
+ if (result.health?.ok) {
880
+ const ver = result.health.version ? ` (version: ${result.health.version})` : "";
881
+ console.log(` ✓ ${tr(zh, `Server reachable${ver}`, `服务器可达${ver}`)}`);
882
+ printCompatibilityWarning(zh, result.health);
883
+ } else if (result.health) {
884
+ console.log(` ✗ ${tr(zh, `Server unreachable: ${result.health.error}`, `服务器不可达: ${result.health.error}`)}`);
885
+ }
886
+
887
+ if (result.keyProbe) {
888
+ printKeyProbeWarning(zh, result.keyProbe);
889
+ }
890
+ console.log("");
891
+ }
892
+
893
+ async function setupRemote(
894
+ zh: boolean,
895
+ configPath: string,
896
+ existing: Record<string, unknown> | null,
897
+ q: (prompt: string, def?: string) => Promise<string>,
898
+ ): Promise<void> {
899
+ console.log("");
900
+ console.log(tr(zh, "── Remote Mode Configuration ──", "── 远程模式配置 ──"));
901
+ console.log("");
902
+
903
+ const defaultUrl = existing?.baseUrl && String(existing.baseUrl).trim()
904
+ ? String(existing.baseUrl)
905
+ : DEFAULT_REMOTE_URL;
906
+ const defaultApiKey = existing?.apiKey ? String(existing.apiKey) : "";
907
+ const defaultAgentPrefix = existing?.agent_prefix ? String(existing.agent_prefix) : "";
908
+
909
+ const baseUrl = await q(tr(zh, "OpenViking server URL", "OpenViking 服务器地址"), defaultUrl);
910
+ const apiKey = await q(tr(zh, "API Key (optional)", "API Key(可选)"), defaultApiKey);
911
+
912
+ let accountId = existing?.accountId ? String(existing.accountId) : "";
913
+ let userId = existing?.userId ? String(existing.userId) : "";
914
+
915
+ if (apiKey) {
916
+ console.log(tr(zh, " Detecting API key type...", " 正在检测 API Key 类型..."));
917
+ const probe = await probeApiKeyType(baseUrl, apiKey);
918
+ if (probe.keyType === "root_key") {
919
+ console.log(tr(zh,
920
+ " ⚠ Root API key detected. accountId and userId are required.",
921
+ " ⚠ 检测到 Root API Key,需要提供 accountId 和 userId。",
922
+ ));
923
+ accountId = await q(tr(zh, "Account ID (required for root key)", "Account ID(root key 必填)"), accountId);
924
+ userId = await q(tr(zh, "User ID (required for root key)", "User ID(root key 必填)"), userId);
925
+ } else if (probe.keyType === "user_key") {
926
+ console.log(tr(zh, " ✓ User key verified", " ✓ User key 已验证"));
927
+ }
928
+ }
929
+
930
+ const agentPrefix = await askAgentPrefix(zh, q, defaultAgentPrefix);
931
+
932
+ console.log("");
933
+
934
+ console.log(tr(zh, `Testing connectivity to ${baseUrl}...`, `正在测试连接 ${baseUrl}...`));
935
+ const health = await checkServiceHealth(baseUrl, apiKey || undefined);
936
+ if (health.ok) {
937
+ const ver = health.version ? ` (version: ${health.version})` : "";
938
+ console.log(` ✓ ${tr(zh, `Connected successfully${ver}`, `连接成功${ver}`)}`);
939
+ printCompatibilityWarning(zh, health);
940
+ } else {
941
+ console.log(` ✗ ${tr(zh, `Connection failed: ${health.error}`, `连接失败: ${health.error}`)}`);
942
+ console.log("");
943
+ console.log(tr(zh,
944
+ " The configuration will still be saved. Make sure the server is reachable\n before starting the gateway.",
945
+ " 配置仍会保存。请确保服务器在启动 gateway 前可达。",
946
+ ));
947
+ }
948
+ console.log("");
949
+
950
+ const pluginCfg: Record<string, unknown> = {
951
+ ...(existing ?? {}),
952
+ mode: "remote",
953
+ baseUrl,
954
+ };
955
+ if (apiKey) pluginCfg.apiKey = apiKey;
956
+ else delete pluginCfg.apiKey;
957
+ if (agentPrefix) pluginCfg.agent_prefix = agentPrefix;
958
+ else delete pluginCfg.agent_prefix;
959
+ if (accountId) pluginCfg.accountId = accountId;
960
+ else delete pluginCfg.accountId;
961
+ if (userId) pluginCfg.userId = userId;
962
+ else delete pluginCfg.userId;
963
+ delete pluginCfg.configPath;
964
+ delete pluginCfg.port;
965
+
966
+ writeConfig(configPath, pluginCfg);
967
+
968
+ const slotResult = activateContextEngineSlot(configPath);
969
+
970
+ console.log("");
971
+ console.log(` ${tr(zh, "mode:", "模式:")} remote`);
972
+ console.log(` baseUrl: ${baseUrl}`);
973
+ if (apiKey) console.log(` apiKey: ${maskKey(apiKey)}`);
974
+ if (agentPrefix) console.log(` agent_prefix: ${agentPrefix}`);
975
+ if (accountId) console.log(` accountId: ${accountId}`);
976
+ if (userId) console.log(` userId: ${userId}`);
977
+ printSlotResult(zh, slotResult);
978
+ console.log("");
979
+ console.log(tr(zh,
980
+ "Run `openclaw gateway --force` to activate the plugin.",
981
+ "运行 `openclaw gateway --force` 以激活插件。",
982
+ ));
983
+ console.log("");
984
+ }
985
+
986
+ export const __test__ = {
987
+ isLegacyLocalMode,
988
+ isValidAgentPrefixInput,
989
+ activateContextEngineSlot,
990
+ isContextEngineSlotActive,
991
+ getStatus,
992
+ setupNonInteractive,
993
+ checkVersionCompatibility,
994
+ parseVersionTuple,
995
+ compareVersions,
996
+ probeApiKeyType,
997
+ ensureInstallRecord,
998
+ findPluginPackageRoot,
999
+ readCompatRangeFromManifest,
1000
+ readPluginVersion,
1001
+ setExitCodeOnFailure,
1002
+ };