@latitude-data/openclaw-telemetry-cli 0.0.7

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/dist/cli.js ADDED
@@ -0,0 +1,1052 @@
1
+ #!/usr/bin/env node
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, isAbsolute, join, resolve } from "node:path";
4
+ import { cancel, confirm, intro, isCancel, log, note, outro, password, spinner, text } from "@clack/prompts";
5
+ import pc from "picocolors";
6
+ import { homedir } from "node:os";
7
+ import { spawnSync } from "node:child_process";
8
+ import { fileURLToPath } from "node:url";
9
+ //#region src/config-dir.ts
10
+ function resolveConfigDir(opts = {}) {
11
+ const cwd = opts.cwd ?? process.cwd();
12
+ const home = opts.home ?? homedir();
13
+ if (typeof opts.flag === "string" && opts.flag.length > 0) return {
14
+ dir: absolutize(opts.flag, cwd),
15
+ source: "flag"
16
+ };
17
+ const envValue = opts.env !== void 0 ? opts.env : process.env.OPENCLAW_HOME;
18
+ if (typeof envValue === "string" && envValue.length > 0) return {
19
+ dir: absolutize(envValue, cwd),
20
+ source: "env"
21
+ };
22
+ if (existsSync(join(cwd, "openclaw.json"))) return {
23
+ dir: cwd,
24
+ source: "cwd"
25
+ };
26
+ return {
27
+ dir: join(home, ".openclaw"),
28
+ source: "default"
29
+ };
30
+ }
31
+ function absolutize(p, cwd) {
32
+ return isAbsolute(p) ? p : resolve(cwd, p);
33
+ }
34
+ function pathsFor(configDir) {
35
+ return {
36
+ configDir,
37
+ settingsPath: join(configDir, "openclaw.json"),
38
+ settingsBackupPath: join(configDir, "openclaw.json.latitude-bak"),
39
+ installsPath: join(configDir, "plugins", "installs.json")
40
+ };
41
+ }
42
+ //#endregion
43
+ //#region src/diff.ts
44
+ /**
45
+ * Tiny purpose-built JSON line-diff for `--dry-run` output. Renders changes
46
+ * to `openclaw.json` in a human-readable unified-diff-ish format without
47
+ * pulling in a real diff library (zero new deps).
48
+ *
49
+ * The shape of `openclaw.json` is small (a few hundred lines tops, with
50
+ * stable key ordering after our edits), so the naive line-by-line approach
51
+ * produces a perfectly readable output. We mark added lines with `+ ` and
52
+ * removed lines with `- `, with a few lines of context around each change.
53
+ */
54
+ const CONTEXT_LINES = 3;
55
+ /**
56
+ * Render a unified-ish diff between two unknown JSON values. Returns an
57
+ * empty string if they're identical after normalization.
58
+ */
59
+ function jsonDiff(before, after, opts = {}) {
60
+ const fromLabel = opts.fromLabel ?? "current";
61
+ const toLabel = opts.toLabel ?? "proposed";
62
+ const beforeJson = `${JSON.stringify(before ?? {}, null, 2)}\n`;
63
+ const afterJson = `${JSON.stringify(after ?? {}, null, 2)}\n`;
64
+ if (beforeJson === afterJson) return "";
65
+ const ops = lcsDiff(beforeJson.split("\n"), afterJson.split("\n"));
66
+ const out = [];
67
+ out.push(`--- ${fromLabel}`);
68
+ out.push(`+++ ${toLabel}`);
69
+ let i = 0;
70
+ while (i < ops.length) {
71
+ if (ops[i]?.kind === "eq") {
72
+ i++;
73
+ continue;
74
+ }
75
+ const hunkStart = Math.max(0, i - CONTEXT_LINES);
76
+ let hunkEnd = i;
77
+ while (hunkEnd < ops.length) {
78
+ if (ops[hunkEnd]?.kind === "eq") {
79
+ let runOfEq = 0;
80
+ let k = hunkEnd;
81
+ while (k < ops.length && ops[k]?.kind === "eq" && runOfEq < CONTEXT_LINES * 2) {
82
+ runOfEq++;
83
+ k++;
84
+ }
85
+ if (k >= ops.length || ops[k]?.kind === "eq") {
86
+ hunkEnd = Math.min(hunkEnd + CONTEXT_LINES, ops.length);
87
+ break;
88
+ }
89
+ hunkEnd = k;
90
+ continue;
91
+ }
92
+ hunkEnd++;
93
+ }
94
+ for (let j = hunkStart; j < hunkEnd && j < ops.length; j++) {
95
+ const op = ops[j];
96
+ if (!op) continue;
97
+ if (op.kind === "eq") out.push(` ${op.line}`);
98
+ else if (op.kind === "add") out.push(`+ ${op.line}`);
99
+ else if (op.kind === "del") out.push(`- ${op.line}`);
100
+ }
101
+ i = hunkEnd;
102
+ }
103
+ return out.join("\n");
104
+ }
105
+ function lcsDiff(a, b) {
106
+ const n = a.length;
107
+ const m = b.length;
108
+ const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
109
+ for (let i = 1; i <= n; i++) for (let j = 1; j <= m; j++) if (a[i - 1] === b[j - 1]) dp[i][j] = (dp[i - 1]?.[j - 1] ?? 0) + 1;
110
+ else dp[i][j] = Math.max(dp[i - 1]?.[j] ?? 0, dp[i]?.[j - 1] ?? 0);
111
+ const ops = [];
112
+ let i = n;
113
+ let j = m;
114
+ while (i > 0 && j > 0) if (a[i - 1] === b[j - 1]) {
115
+ ops.push({
116
+ kind: "eq",
117
+ line: a[i - 1]
118
+ });
119
+ i--;
120
+ j--;
121
+ } else if ((dp[i - 1]?.[j] ?? 0) >= (dp[i]?.[j - 1] ?? 0)) {
122
+ ops.push({
123
+ kind: "del",
124
+ line: a[i - 1]
125
+ });
126
+ i--;
127
+ } else {
128
+ ops.push({
129
+ kind: "add",
130
+ line: b[j - 1]
131
+ });
132
+ j--;
133
+ }
134
+ while (i > 0) {
135
+ ops.push({
136
+ kind: "del",
137
+ line: a[i - 1]
138
+ });
139
+ i--;
140
+ }
141
+ while (j > 0) {
142
+ ops.push({
143
+ kind: "add",
144
+ line: b[j - 1]
145
+ });
146
+ j--;
147
+ }
148
+ ops.reverse();
149
+ return ops;
150
+ }
151
+ //#endregion
152
+ //#region src/openclaw-cli.ts
153
+ /**
154
+ * Lowest OpenClaw version we support. The reporter verified hook-dispatch
155
+ * gating works correctly here; older versions either reject
156
+ * `hooks.allowConversationAccess` outright (≤ 2026.4.21) or have unverified
157
+ * gating behaviour (2026.4.22 – 2026.4.24). Refusing to install on older
158
+ * versions is intentional — we'd rather fail loudly than ship a
159
+ * config the gateway will quarantine or hooks the dispatcher will block.
160
+ */
161
+ const MIN_OPENCLAW_VERSION = "2026.4.25";
162
+ const DEFAULT_TIMEOUT_MS = 1e4;
163
+ /**
164
+ * Spawn `openclaw <args>` synchronously. Reports failure modes structurally so
165
+ * callers can decide how to degrade (missing binary vs. timed out vs. exited
166
+ * non-zero). Never throws; ENOENT becomes `{ reason: "enoent" }`.
167
+ *
168
+ * `opts.env` lets callers overlay environment variables on top of `process.env`
169
+ * — used by the install flow to inject `OPENCLAW_HOME` so `openclaw plugins
170
+ * install`, `openclaw config validate`, and `openclaw gateway restart` all
171
+ * resolve the same config dir as our settings-file edits. The overlay is
172
+ * additive: the caller passes only the keys to override and we merge them on
173
+ * top of `process.env`.
174
+ */
175
+ function runOpenclaw(args, opts = {}) {
176
+ const env = opts.env ? {
177
+ ...process.env,
178
+ ...opts.env
179
+ } : void 0;
180
+ const result = spawnSync("openclaw", args, {
181
+ encoding: "utf-8",
182
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
183
+ input: opts.stdin,
184
+ ...env ? { env } : {},
185
+ stdio: [
186
+ "pipe",
187
+ "pipe",
188
+ "pipe"
189
+ ]
190
+ });
191
+ const err = result.error;
192
+ if (err?.code === "ENOENT") return {
193
+ ok: false,
194
+ reason: "enoent",
195
+ stdout: "",
196
+ stderr: "",
197
+ code: null
198
+ };
199
+ if (err?.code === "ETIMEDOUT" || result.signal === "SIGTERM" || result.signal === "SIGKILL") return {
200
+ ok: false,
201
+ reason: "timeout",
202
+ stdout: result.stdout ?? "",
203
+ stderr: result.stderr ?? "",
204
+ code: null
205
+ };
206
+ if (err) return {
207
+ ok: false,
208
+ reason: "exit",
209
+ stdout: result.stdout ?? "",
210
+ stderr: result.stderr ?? String(err),
211
+ code: typeof result.status === "number" ? result.status : 1
212
+ };
213
+ if (result.status === 0) return {
214
+ ok: true,
215
+ stdout: result.stdout ?? "",
216
+ stderr: result.stderr ?? "",
217
+ code: 0
218
+ };
219
+ return {
220
+ ok: false,
221
+ reason: "exit",
222
+ stdout: result.stdout ?? "",
223
+ stderr: result.stderr ?? "",
224
+ code: typeof result.status === "number" ? result.status : 1
225
+ };
226
+ }
227
+ /**
228
+ * Run `openclaw --version` and parse out the version string.
229
+ *
230
+ * Banner format (per OpenClaw `src/cli/banner.ts` `formatCliBannerLine`):
231
+ * `🦞 OpenClaw <version> (<commit-sha>)`
232
+ *
233
+ * The banner is normally suppressed when `--version` is the flag, but the
234
+ * version itself still goes to stdout. We accept either layout (with or
235
+ * without the lobster + commit sha) so we don't break if OpenClaw later
236
+ * prints just the bare version string.
237
+ */
238
+ function getOpenclawVersion() {
239
+ const result = runOpenclaw(["--version"], { timeoutMs: 5e3 });
240
+ if (!result.ok) {
241
+ if (result.reason === "enoent") return {
242
+ ok: false,
243
+ error: "missing"
244
+ };
245
+ return {
246
+ ok: false,
247
+ error: "unparseable",
248
+ raw: result.stdout || result.stderr
249
+ };
250
+ }
251
+ const raw = result.stdout.trim();
252
+ const match = raw.match(/(\d{4}\.\d+\.\d+)/);
253
+ if (!match) return {
254
+ ok: false,
255
+ error: "unparseable",
256
+ raw
257
+ };
258
+ return {
259
+ ok: true,
260
+ version: match[1],
261
+ raw
262
+ };
263
+ }
264
+ /**
265
+ * Compare two CalVer strings (`YYYY.M.PATCH`). Returns -1 if `a` < `b`,
266
+ * 0 if equal, 1 if `a` > `b`. Strings with extra components or non-numeric
267
+ * pieces fall back to a per-component string comparison so unexpected
268
+ * formats don't crash the installer.
269
+ */
270
+ function compareCalver(a, b) {
271
+ const ap = a.split(".");
272
+ const bp = b.split(".");
273
+ const len = Math.max(ap.length, bp.length);
274
+ for (let i = 0; i < len; i++) {
275
+ const ai = ap[i] ?? "0";
276
+ const bi = bp[i] ?? "0";
277
+ const an = Number(ai);
278
+ const bn = Number(bi);
279
+ if (Number.isFinite(an) && Number.isFinite(bn)) {
280
+ if (an < bn) return -1;
281
+ if (an > bn) return 1;
282
+ continue;
283
+ }
284
+ if (ai < bi) return -1;
285
+ if (ai > bi) return 1;
286
+ }
287
+ return 0;
288
+ }
289
+ //#endregion
290
+ //#region src/settings-file.ts
291
+ /** Plugin id used both as the npm package name and as the OpenClaw plugin id. */
292
+ const PLUGIN_ID = "@latitude-data/openclaw-telemetry";
293
+ function readSettings(settingsPath) {
294
+ if (!existsSync(settingsPath)) return {};
295
+ try {
296
+ const raw = readFileSync(settingsPath, "utf-8");
297
+ const parsed = JSON.parse(raw);
298
+ return parsed && typeof parsed === "object" ? parsed : {};
299
+ } catch {
300
+ return {};
301
+ }
302
+ }
303
+ /**
304
+ * Write `openclaw.json` atomically: serialize to a sibling tempfile, then
305
+ * rename over the target. `rename` is atomic on POSIX and effectively atomic
306
+ * on Windows for our case — a crash mid-serialization can no longer leave a
307
+ * truncated `openclaw.json` (combined with `.latitude-bak`, recovery is
308
+ * always possible).
309
+ *
310
+ * Earlier versions used `writeFileSync` directly; that left a corruption
311
+ * window where SIGTERM between `open()` and the final `write()` would leave
312
+ * a partial JSON file the gateway couldn't parse.
313
+ */
314
+ function writeSettings(settingsPath, settings) {
315
+ const tmp = `${settingsPath}.tmp.${process.pid}`;
316
+ writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
317
+ renameSync(tmp, settingsPath);
318
+ }
319
+ function backupSettings(settingsPath, backupPath) {
320
+ if (existsSync(settingsPath)) copyFileSync(settingsPath, backupPath);
321
+ }
322
+ /**
323
+ * Restore the backup over the live settings file. Used when a post-write
324
+ * step (e.g. `openclaw config validate --json`) reports the new file is
325
+ * invalid — better to roll back to a known-good state than leave a broken
326
+ * config that'll fail at gateway-restart time.
327
+ */
328
+ function restoreBackup(settingsPath, backupPath) {
329
+ if (!existsSync(backupPath)) return false;
330
+ copyFileSync(backupPath, settingsPath);
331
+ return true;
332
+ }
333
+ /**
334
+ * Set the `plugins.entries[id]` block for our plugin.
335
+ *
336
+ * Two places in the entry get written:
337
+ *
338
+ * - `.config` (free-form `record(string, unknown)`): credentials, baseUrl,
339
+ * and our copy of `allowConversationAccess`. This is what the plugin
340
+ * runtime reads via `api.pluginConfig`.
341
+ * - `.hooks.allowConversationAccess`: controls whether OpenClaw's hook
342
+ * dispatcher actually forwards `llm_input` / `llm_output` / tool /
343
+ * `agent_end` events to our handlers. Without it set to `true` on
344
+ * OpenClaw 2026.4.25+, every typed hook is blocked at the dispatcher
345
+ * and the plugin's handlers never fire.
346
+ *
347
+ * The two flags mean different things — `hooks.*` is the dispatch gate,
348
+ * `config.*` is the payload-content gate — but for THIS plugin we always
349
+ * couple them: dispatch off + payload on is useless (no payloads to gate),
350
+ * and dispatch on + payload off is a legitimate "structural-only telemetry"
351
+ * mode (timing, tokens, ids, agent name; no message bodies). Always writing
352
+ * both from the same source keeps the operator's mental model simple.
353
+ *
354
+ * Re-install idempotency: only `apiKey` / `project` / `baseUrl` always
355
+ * overwrite (these come from install prompts). `enabled`, `debug`, and
356
+ * `allowConversationAccess` are preserved when not provided in the patch.
357
+ */
358
+ function setPluginEntry(settings, patch) {
359
+ const plugins = settings.plugins ?? {};
360
+ const entries = plugins.entries ?? {};
361
+ const existing = entries["@latitude-data/openclaw-telemetry"] ?? {};
362
+ const existingConfig = existing.config ?? {};
363
+ const existingHooks = existing.hooks ?? {};
364
+ const nextConfig = {
365
+ ...existingConfig,
366
+ apiKey: patch.apiKey,
367
+ project: patch.project
368
+ };
369
+ if (patch.baseUrl !== void 0) nextConfig.baseUrl = patch.baseUrl;
370
+ else delete nextConfig.baseUrl;
371
+ if (patch.allowConversationAccess !== void 0) nextConfig.allowConversationAccess = patch.allowConversationAccess;
372
+ if (patch.debug !== void 0) nextConfig.debug = patch.debug;
373
+ const effectiveAccess = typeof nextConfig.allowConversationAccess === "boolean" ? nextConfig.allowConversationAccess : typeof existingHooks.allowConversationAccess === "boolean" ? existingHooks.allowConversationAccess : true;
374
+ const nextHooks = {
375
+ ...existingHooks,
376
+ allowConversationAccess: effectiveAccess
377
+ };
378
+ const nextEnabled = patch.enabled ?? existing.enabled ?? true;
379
+ entries[PLUGIN_ID] = {
380
+ ...existing,
381
+ enabled: nextEnabled,
382
+ hooks: nextHooks,
383
+ config: nextConfig
384
+ };
385
+ plugins.entries = entries;
386
+ settings.plugins = plugins;
387
+ }
388
+ /** Remove the plugin entry entirely. Used by uninstall as defense-in-depth. */
389
+ function removePluginEntry(settings) {
390
+ const plugins = settings.plugins;
391
+ if (!plugins?.entries) return false;
392
+ if (!("@latitude-data/openclaw-telemetry" in plugins.entries)) return false;
393
+ delete plugins.entries[PLUGIN_ID];
394
+ return true;
395
+ }
396
+ /**
397
+ * Add the plugin id to `plugins.allow`. Idempotent — returns `true` only when
398
+ * the array changed. OpenClaw warns at every gateway start when a non-bundled
399
+ * plugin auto-loads without provenance via `plugins.allow` or an install
400
+ * record. We get one warning cleared by going through `openclaw plugins
401
+ * install` (provenance) and the other by adding ourselves to allow.
402
+ *
403
+ * Defensive against hand-edited non-array values: if `plugins.allow` is
404
+ * present but not an array (e.g. someone wrote a string), we replace it
405
+ * with a single-element array rather than spreading the bad value.
406
+ */
407
+ function addToPluginsAllow(settings) {
408
+ const plugins = settings.plugins ?? {};
409
+ const existing = plugins.allow;
410
+ const allow = Array.isArray(existing) ? existing : [];
411
+ if (allow.includes("@latitude-data/openclaw-telemetry")) return false;
412
+ plugins.allow = [...allow, PLUGIN_ID];
413
+ settings.plugins = plugins;
414
+ return true;
415
+ }
416
+ /**
417
+ * Inverse of `addToPluginsAllow`. Defense-in-depth — `openclaw plugins
418
+ * uninstall` already strips the entry, but the install path can be skipped
419
+ * (e.g. the user removed the plugin manually) and we want re-install/uninstall
420
+ * round-trips to be tidy regardless.
421
+ */
422
+ function removeFromPluginsAllow(settings) {
423
+ const allow = settings.plugins?.allow;
424
+ if (!Array.isArray(allow) || !allow.includes("@latitude-data/openclaw-telemetry")) return false;
425
+ if (settings.plugins) settings.plugins.allow = allow.filter((id) => id !== PLUGIN_ID);
426
+ return true;
427
+ }
428
+ function hasLatitudePlugin(settings) {
429
+ return Boolean(settings.plugins?.entries && "@latitude-data/openclaw-telemetry" in settings.plugins.entries);
430
+ }
431
+ /**
432
+ * Strip leftover keys from older installers that the strict zod schema
433
+ * rejects on current OpenClaw versions.
434
+ *
435
+ * 0.0.1 wrote `LATITUDE_*` keys directly under `settings.env`. OpenClaw's
436
+ * root schema is strict; the `env` block accepts only `{shellEnv, vars}`,
437
+ * so those keys cause the gateway to quarantine the config as
438
+ * `clobbered.<ts>` and roll back. We sweep them on every install.
439
+ *
440
+ * Note: 0.0.1 also wrote `hooks.allowConversationAccess` (when that key was
441
+ * not yet in the schema). We deliberately do NOT strip it anymore — on
442
+ * OpenClaw 2026.4.25+ the key IS in the schema and IS load-bearing for
443
+ * dispatch. `setPluginEntry` overwrites it on every install with the right
444
+ * value, so any 0.0.1 leftover is reconciled there.
445
+ */
446
+ function migrateLegacyEntries(settings) {
447
+ let changed = false;
448
+ const env = settings.env;
449
+ if (env && typeof env === "object" && !Array.isArray(env)) {
450
+ const envObj = env;
451
+ for (const key of [
452
+ "LATITUDE_API_KEY",
453
+ "LATITUDE_PROJECT",
454
+ "LATITUDE_BASE_URL"
455
+ ]) if (key in envObj) {
456
+ delete envObj[key];
457
+ changed = true;
458
+ }
459
+ if (Object.keys(envObj).length === 0) delete settings.env;
460
+ }
461
+ return { changed };
462
+ }
463
+ /**
464
+ * Read OpenClaw's plugins install record (`<configDir>/plugins/installs.json`)
465
+ * and return the version recorded for our plugin id, if any. Used by the
466
+ * install flow to render `Upgrading 0.0.6 → 0.0.7` UX.
467
+ *
468
+ * Best-effort: returns undefined on any read/parse error, on missing file,
469
+ * on schemas we don't recognize. Callers must handle the undefined case
470
+ * (typically by rendering a "fresh install" path).
471
+ */
472
+ function readInstalledRuntimeVersion(installsPath) {
473
+ if (!existsSync(installsPath)) return void 0;
474
+ try {
475
+ const raw = readFileSync(installsPath, "utf-8");
476
+ const parsed = JSON.parse(raw);
477
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return void 0;
478
+ const entries = parsed.entries;
479
+ if (!entries || typeof entries !== "object") return void 0;
480
+ const ours = entries[PLUGIN_ID];
481
+ if (!ours || typeof ours !== "object") return void 0;
482
+ const version = ours.version;
483
+ return typeof version === "string" ? version : void 0;
484
+ } catch {
485
+ return;
486
+ }
487
+ }
488
+ //#endregion
489
+ //#region src/version.ts
490
+ /**
491
+ * Read this CLI's own version from its `package.json`. Used by:
492
+ * - `cli.ts` for `--version`
493
+ * - `setup.ts` in the npm-registry-404 abort message, so the upgrade
494
+ * instruction shows the version the user is actually running. Earlier
495
+ * versions read `process.env.npm_package_version`, but that env var
496
+ * is only set by `npm run` — not by `npx`, not by globally-installed
497
+ * bins. The vast majority of `latitude-openclaw` invocations come
498
+ * through one of those, so the env-var approach printed
499
+ * `(this version)` instead of the real version every time.
500
+ */
501
+ function readCliVersion() {
502
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
503
+ try {
504
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "unknown";
505
+ } catch {
506
+ return "unknown";
507
+ }
508
+ }
509
+ //#endregion
510
+ //#region src/setup.ts
511
+ /**
512
+ * The runtime version this CLI installs. Lockstep with the runtime — bump
513
+ * both packages in the same commit. The CLI's npm-spec install is pinned to
514
+ * this exact version (not a floating tag), so we never accidentally install
515
+ * a runtime that doesn't match the contract this CLI was built against.
516
+ *
517
+ * Pinning also satisfies OpenClaw's `Pin install specs to exact versions`
518
+ * supply-chain audit warning automatically.
519
+ */
520
+ const RUNTIME_PACKAGE_NAME = "@latitude-data/openclaw-telemetry";
521
+ const RUNTIME_VERSION = "0.0.7";
522
+ const DOCS_URL = "https://docs.latitude.so/openclaw-telemetry";
523
+ const PRODUCTION_ENV = {
524
+ name: "production",
525
+ label: "production",
526
+ app: "https://console.latitude.so",
527
+ ingest: "https://ingest.latitude.so"
528
+ };
529
+ const STAGING_ENV = {
530
+ name: "staging",
531
+ label: "staging",
532
+ app: "https://staging.latitude.so",
533
+ ingest: "https://staging-ingest.latitude.so"
534
+ };
535
+ const DEV_ENV = {
536
+ name: "dev",
537
+ label: "local dev",
538
+ app: "http://localhost:3000",
539
+ ingest: "http://localhost:3002"
540
+ };
541
+ function urlsFor(env) {
542
+ return {
543
+ apiKeys: `${env.app}/settings/api-keys`,
544
+ projects: env.app,
545
+ projectView: (slug) => `${env.app}/projects/${slug}`
546
+ };
547
+ }
548
+ /**
549
+ * Flags that take a value (either `--key=value` or `--key value`). Bare
550
+ * `--key` is reserved for booleans and shouldn't consume the next argv
551
+ * token as its value — operators expect `--no-content` next to `--yes`
552
+ * to mean two booleans, not "no-content takes the value `--yes`".
553
+ */
554
+ const VALUE_FLAGS = new Set([
555
+ "api-key",
556
+ "project",
557
+ "openclaw-dir"
558
+ ]);
559
+ function parseFlags(argv) {
560
+ const [subcommand, ...rest] = argv;
561
+ const flags = {};
562
+ for (let i = 0; i < rest.length; i++) {
563
+ const arg = rest[i];
564
+ if (!arg || !arg.startsWith("--")) continue;
565
+ const eq = arg.indexOf("=");
566
+ if (eq >= 0) {
567
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
568
+ continue;
569
+ }
570
+ const key = arg.slice(2);
571
+ const next = rest[i + 1];
572
+ if (VALUE_FLAGS.has(key) && next !== void 0 && !next.startsWith("--")) {
573
+ flags[key] = next;
574
+ i += 1;
575
+ } else flags[key] = true;
576
+ }
577
+ return {
578
+ subcommand,
579
+ flags
580
+ };
581
+ }
582
+ function normalizeInstallFlags(flags) {
583
+ let environment;
584
+ if (flags.staging === true) environment = STAGING_ENV;
585
+ if (flags.dev === true) {
586
+ if (environment) throw new Error("--staging and --dev are mutually exclusive");
587
+ environment = DEV_ENV;
588
+ }
589
+ let allowConversationAccess;
590
+ if (flags["no-content"] === true || flags["no-conversation"] === true) allowConversationAccess = false;
591
+ if (flags["allow-conversation"] === true) allowConversationAccess = true;
592
+ let restart = "auto";
593
+ if (flags["no-restart"] === true) restart = "never";
594
+ if (flags.restart === true) {
595
+ if (flags["no-restart"] === true) throw new Error("--restart and --no-restart are mutually exclusive");
596
+ restart = "force";
597
+ }
598
+ return {
599
+ apiKey: typeof flags["api-key"] === "string" ? flags["api-key"] : void 0,
600
+ project: typeof flags.project === "string" ? flags.project : void 0,
601
+ environment,
602
+ allowConversationAccess,
603
+ noTrust: flags["no-trust"] === true,
604
+ openclawDir: typeof flags["openclaw-dir"] === "string" ? flags["openclaw-dir"] : void 0,
605
+ dryRun: flags["dry-run"] === true,
606
+ restart,
607
+ noPrompt: flags["no-prompt"] === true || flags.yes === true,
608
+ yes: flags.yes === true
609
+ };
610
+ }
611
+ async function runInstall(flags = {}) {
612
+ if (!(!flags.noPrompt && process.stdin.isTTY === true)) return runFlagDrivenInstall(flags);
613
+ await runInteractiveInstall(flags);
614
+ }
615
+ function resolvePaths(flags) {
616
+ const resolved = resolveConfigDir({ flag: flags.openclawDir });
617
+ return {
618
+ resolved,
619
+ paths: pathsFor(resolved.dir)
620
+ };
621
+ }
622
+ function envForSubprocess(paths) {
623
+ return { OPENCLAW_HOME: paths.configDir };
624
+ }
625
+ async function runInteractiveInstall(flags) {
626
+ intro(pc.bgCyan(pc.black(" Latitude · OpenClaw telemetry ")));
627
+ ensureOpenclawIsCompatible();
628
+ const { resolved, paths } = resolvePaths(flags);
629
+ log.info(`Using config dir: ${pc.dim(paths.configDir)} ${pc.dim(`(source: ${resolved.source})`)}`);
630
+ await ensureRuntimeOnNpm();
631
+ const installedVersion = readInstalledRuntimeVersion(paths.installsPath);
632
+ if (installedVersion !== void 0) if (installedVersion === RUNTIME_VERSION) log.info(`${PLUGIN_ID} ${pc.dim(installedVersion)} already installed — re-applying (idempotent).`);
633
+ else log.info(`Upgrading ${PLUGIN_ID} from ${pc.dim(installedVersion)} → ${pc.cyan(RUNTIME_VERSION)}`);
634
+ const existingConfig = readSettings(paths.settingsPath).plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? void 0;
635
+ const envConfig = flags.environment ?? PRODUCTION_ENV;
636
+ const urls = urlsFor(envConfig);
637
+ const aboutLines = [
638
+ "Captures every OpenClaw agent run and ships it to Latitude as",
639
+ "OpenTelemetry traces — full system prompt, tool I/O, messages,",
640
+ "token usage, and agent name on every span.",
641
+ "",
642
+ `${pc.dim("Docs")} ${pc.cyan(DOCS_URL)}`
643
+ ];
644
+ if (envConfig.name !== "production") aboutLines.push("", pc.yellow(`Using ${envConfig.label} environment (${envConfig.ingest})`));
645
+ note(aboutLines.join("\n"), "About");
646
+ log.info(`Get an API key at ${pc.cyan(urls.apiKeys)}`);
647
+ log.info(`Create a project at ${pc.cyan(urls.projects)}`);
648
+ const apiKey = await promptApiKey(existingConfig?.apiKey, flags.apiKey);
649
+ const project = await promptProject(existingConfig?.project, flags.project);
650
+ await applyChanges({
651
+ apiKey,
652
+ project,
653
+ envConfig,
654
+ allowConversationAccess: flags.allowConversationAccess,
655
+ noTrust: flags.noTrust === true,
656
+ paths,
657
+ dryRun: flags.dryRun === true
658
+ });
659
+ if (!flags.dryRun) await maybeRestartGateway(flags.restart ?? "auto", paths);
660
+ note([flags.dryRun ? "Dry-run only — nothing was written." : "Plugin installed and configured.", `View your traces at ${pc.cyan(urls.projectView(project))}`].join("\n"), "Next step");
661
+ outro(flags.dryRun ? pc.dim("(dry-run)") : pc.green("✓ Installed"));
662
+ }
663
+ async function runFlagDrivenInstall(flags) {
664
+ ensureOpenclawIsCompatible();
665
+ const { resolved, paths } = resolvePaths(flags);
666
+ process.stdout.write(`Using config dir: ${paths.configDir} (source: ${resolved.source})\n`);
667
+ await ensureRuntimeOnNpm();
668
+ const apiKey = flags.apiKey;
669
+ const project = flags.project;
670
+ if (!apiKey || !project) throw new Error("Non-interactive install requires --api-key=... and --project=... (or run in a TTY).");
671
+ await applyChanges({
672
+ apiKey,
673
+ project,
674
+ envConfig: flags.environment ?? PRODUCTION_ENV,
675
+ allowConversationAccess: flags.allowConversationAccess,
676
+ noTrust: flags.noTrust === true,
677
+ paths,
678
+ dryRun: flags.dryRun === true
679
+ });
680
+ if (flags.dryRun) {
681
+ process.stdout.write("Dry-run only — nothing was written.\n");
682
+ return;
683
+ }
684
+ await maybeRestartGateway(flags.restart ?? "auto", paths);
685
+ process.stdout.write(`Installed Latitude plugin in ${paths.settingsPath}\n`);
686
+ }
687
+ async function promptApiKey(_existing, flag) {
688
+ if (flag) return flag;
689
+ const result = await password({
690
+ message: "Latitude API key",
691
+ mask: "•",
692
+ validate: (v) => v && v.length > 0 ? void 0 : "Required"
693
+ });
694
+ if (isCancel(result)) return onCancel();
695
+ return result;
696
+ }
697
+ async function promptProject(existing, flag) {
698
+ if (flag) return flag;
699
+ const result = await text({
700
+ message: "Latitude project slug",
701
+ placeholder: existing ?? "my-openclaw-project",
702
+ ...existing ? { initialValue: existing } : {},
703
+ validate: (v) => v && v.length > 0 ? void 0 : "Required"
704
+ });
705
+ if (isCancel(result)) return onCancel();
706
+ return result;
707
+ }
708
+ function onCancel() {
709
+ cancel("Cancelled — nothing was changed");
710
+ process.exit(1);
711
+ }
712
+ async function applyChanges({ apiKey, project, envConfig, allowConversationAccess, noTrust, paths, dryRun }) {
713
+ const installSpec = `${RUNTIME_PACKAGE_NAME}@${RUNTIME_VERSION}`;
714
+ const before = readSettings(paths.settingsPath);
715
+ const after = structuredClone(before);
716
+ migrateLegacyEntries(after);
717
+ setPluginEntry(after, {
718
+ apiKey,
719
+ project,
720
+ baseUrl: envConfig.name === "production" ? void 0 : envConfig.ingest,
721
+ allowConversationAccess
722
+ });
723
+ if (!noTrust) addToPluginsAllow(after);
724
+ if (dryRun) {
725
+ log.info(`Would run: ${pc.dim(`openclaw plugins install ${installSpec} --force`)}`);
726
+ const diff = jsonDiff(before, after, {
727
+ fromLabel: paths.settingsPath,
728
+ toLabel: `${paths.settingsPath} (proposed)`
729
+ });
730
+ if (diff.length > 0) process.stdout.write(`${diff}\n`);
731
+ else log.info(`No openclaw.json changes — current config already matches.`);
732
+ return;
733
+ }
734
+ ensureSettingsDir(paths);
735
+ backupSettings(paths.settingsPath, paths.settingsBackupPath);
736
+ const installSpinner = spinner();
737
+ installSpinner.start(`Installing plugin via openclaw plugins install ${installSpec}`);
738
+ const installResult = runOpenclaw([
739
+ "plugins",
740
+ "install",
741
+ installSpec,
742
+ "--force"
743
+ ], {
744
+ timeoutMs: 6e4,
745
+ env: envForSubprocess(paths)
746
+ });
747
+ if (!installResult.ok) {
748
+ installSpinner.stop("openclaw plugins install failed");
749
+ if (installResult.reason === "enoent") throw new Error("`openclaw` not found on PATH. Install OpenClaw first (https://openclaw.ai/install) and re-run.");
750
+ if (installResult.reason === "timeout") throw new Error("openclaw plugins install timed out after 60s. Try running it manually to see what's stuck.");
751
+ const detail = installResult.stderr.trim() || installResult.stdout.trim() || `exit code ${installResult.code}`;
752
+ throw new Error(`openclaw plugins install failed: ${detail}`);
753
+ }
754
+ installSpinner.stop("Plugin registered with OpenClaw");
755
+ const postInstallSnapshot = readSettings(paths.settingsPath);
756
+ const settingsSpinner = spinner();
757
+ settingsSpinner.start("Updating openclaw.json");
758
+ const settings = readSettings(paths.settingsPath);
759
+ migrateLegacyEntries(settings);
760
+ setPluginEntry(settings, {
761
+ apiKey,
762
+ project,
763
+ baseUrl: envConfig.name === "production" ? void 0 : envConfig.ingest,
764
+ allowConversationAccess
765
+ });
766
+ if (!noTrust) addToPluginsAllow(settings);
767
+ writeSettings(paths.settingsPath, settings);
768
+ settingsSpinner.stop(`Updated ${paths.settingsPath}`);
769
+ if (existsSync(paths.settingsBackupPath)) log.info(`Backup saved at ${pc.dim(paths.settingsBackupPath)}`);
770
+ if (noTrust) log.warning(`--no-trust set; OpenClaw will warn at every gateway start that ${PLUGIN_ID} is untrusted. Add it to plugins.allow yourself when you're ready.`);
771
+ const validateSpinner = spinner();
772
+ validateSpinner.start("Validating openclaw.json");
773
+ const validateResult = runOpenclaw([
774
+ "config",
775
+ "validate",
776
+ "--json"
777
+ ], {
778
+ timeoutMs: 1e4,
779
+ env: envForSubprocess(paths)
780
+ });
781
+ if (!validateResult.ok || isInvalidConfigPayload(validateResult.stdout)) {
782
+ validateSpinner.stop("openclaw config validate failed");
783
+ const rollback = rollbackSettings(paths, postInstallSnapshot);
784
+ const detail = validateResult.ok === false ? validateResult.stderr.trim() || validateResult.stdout.trim() || `exit code ${validateResult.code}` : validateResult.stdout.trim() || "config validate reported invalid config";
785
+ throw new Error(`openclaw config validate reported a problem after our changes:\n ${detail}\n${rollback}`);
786
+ }
787
+ validateSpinner.stop("Config valid");
788
+ }
789
+ /**
790
+ * Two-tier rollback for validation failures:
791
+ *
792
+ * 1. If `.latitude-bak` exists (typical case — operator had openclaw.json
793
+ * before this flow), restore from it. That's the user's true
794
+ * pre-install state.
795
+ * 2. If `.latitude-bak` doesn't exist (fresh install — no openclaw.json
796
+ * pre-flow, so `backupSettings` no-op'd), write `postInstallSnapshot`
797
+ * back. That's the state immediately after `openclaw plugins install`
798
+ * and before any of our config layering — schema-valid, just a
799
+ * disabled plugin entry. Operator can re-run install or run
800
+ * `openclaw plugins uninstall` to fully revert OpenClaw's bookkeeping.
801
+ *
802
+ * Returns the human-readable recovery line to append to the thrown error.
803
+ */
804
+ function rollbackSettings(paths, postInstallSnapshot) {
805
+ if (existsSync(paths.settingsBackupPath)) return restoreBackup(paths.settingsPath, paths.settingsBackupPath) ? `Backup restored from ${paths.settingsBackupPath} — your config is back to the pre-install state.` : `Backup at ${paths.settingsBackupPath} could not be restored automatically; restore it manually.`;
806
+ try {
807
+ writeSettings(paths.settingsPath, postInstallSnapshot);
808
+ return `${paths.settingsPath} rolled back to the post-\`openclaw plugins install\` state (plugin entry exists but disabled). Run \`openclaw plugins uninstall ${PLUGIN_ID} --force\` to fully revert if you don't intend to retry.`;
809
+ } catch (err) {
810
+ return `Couldn't roll back ${paths.settingsPath} (${String(err)}). Run \`openclaw plugins uninstall ${PLUGIN_ID} --force\` and inspect the file manually.`;
811
+ }
812
+ }
813
+ /** Best-effort detection of `{"valid": false, ...}` in the validate output. */
814
+ function isInvalidConfigPayload(stdout) {
815
+ const trimmed = stdout.trim();
816
+ if (!trimmed) return false;
817
+ try {
818
+ return JSON.parse(trimmed).valid === false;
819
+ } catch {
820
+ return false;
821
+ }
822
+ }
823
+ function ensureSettingsDir(paths) {
824
+ const dir = dirname(paths.settingsPath);
825
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
826
+ }
827
+ /**
828
+ * Verify `openclaw` is on PATH AND its version is >= MIN_OPENCLAW_VERSION.
829
+ * Aborts with a clear upgrade message otherwise. Called before any user
830
+ * prompts so we don't waste their time collecting credentials we can't
831
+ * use.
832
+ */
833
+ function ensureOpenclawIsCompatible() {
834
+ const v = getOpenclawVersion();
835
+ if (!v.ok) {
836
+ if (v.error === "missing") {
837
+ cancel("OpenClaw CLI not found on PATH. Install or update via `npm install -g openclaw@latest` and re-run.");
838
+ process.exit(1);
839
+ }
840
+ cancel(`Couldn't parse OpenClaw version output${v.raw ? ` (got: ${pc.dim(v.raw)})` : ""}. Run \`openclaw --version\` and report the output.`);
841
+ process.exit(1);
842
+ }
843
+ if (compareCalver(v.version, "2026.4.25") < 0) {
844
+ cancel(`OpenClaw ${v.version} is older than the minimum supported version (${MIN_OPENCLAW_VERSION}). Run \`npm install -g openclaw@latest\` and re-run install.`);
845
+ process.exit(1);
846
+ }
847
+ log.info(`OpenClaw ${pc.dim(v.version)} (>= ${MIN_OPENCLAW_VERSION})`);
848
+ }
849
+ /**
850
+ * Lockstep contract check. The CLI installs a pinned `RUNTIME_VERSION`; if
851
+ * that version isn't published yet (half-released release pipeline), abort
852
+ * with a clear upgrade path so the operator's gateway doesn't end up with a
853
+ * runtime that doesn't match this CLI's expectations.
854
+ *
855
+ * Best-effort — uses the npm registry's HTTP API rather than spawning
856
+ * `npm view` so we don't require npm to be on PATH. Network failure
857
+ * (offline) → warn-and-continue; the install spawn would fail visibly
858
+ * anyway if the package wasn't there.
859
+ */
860
+ async function ensureRuntimeOnNpm() {
861
+ const url = `https://registry.npmjs.org/${encodeURIComponent(RUNTIME_PACKAGE_NAME)}/${encodeURIComponent(RUNTIME_VERSION)}`;
862
+ try {
863
+ const res = await fetch(url, {
864
+ method: "GET",
865
+ signal: AbortSignal.timeout(5e3)
866
+ });
867
+ if (res.status === 404) {
868
+ cancel(`CLI ${readCliVersion()} expects ${RUNTIME_PACKAGE_NAME}@${RUNTIME_VERSION} but npm doesn't have that exact version. Upgrade the CLI: npm install -g @latitude-data/openclaw-telemetry-cli@latest`);
869
+ process.exit(1);
870
+ }
871
+ if (!res.ok) log.warning(`npm registry check returned HTTP ${res.status}; continuing.`);
872
+ } catch (err) {
873
+ log.warning(`Couldn't reach npm registry to verify ${RUNTIME_PACKAGE_NAME}@${RUNTIME_VERSION}: ${String(err)}`);
874
+ }
875
+ }
876
+ /**
877
+ * Restart the OpenClaw gateway, prompting on TTY by default. The behaviour
878
+ * is governed by `flags.restart`:
879
+ *
880
+ * - `"force"` (--restart): always restart, even non-TTY. CI escape hatch.
881
+ * - `"never"` (--no-restart): never restart, even on TTY.
882
+ * - `"auto"` (default): prompt on TTY ("Restart now? [Y/n]"); skip
883
+ * non-TTY (and print the manual command). Hybrid that keeps interactive
884
+ * UX safe while CI defaults to leaving the operator in control.
885
+ */
886
+ async function maybeRestartGateway(mode, paths) {
887
+ if (mode === "never") {
888
+ log.info(`Skipping gateway restart (--no-restart). Run \`openclaw gateway restart\` when ready.`);
889
+ return;
890
+ }
891
+ let shouldRestart;
892
+ if (mode === "force") shouldRestart = true;
893
+ else {
894
+ if (process.stdin.isTTY !== true) {
895
+ log.info(`Run \`openclaw gateway restart\` to load the plugin.`);
896
+ return;
897
+ }
898
+ const confirmed = await confirm({
899
+ message: "Restart the OpenClaw gateway now to load the plugin?",
900
+ initialValue: true
901
+ });
902
+ if (isCancel(confirmed) || confirmed !== true) {
903
+ log.info(`Run \`openclaw gateway restart\` when ready.`);
904
+ return;
905
+ }
906
+ shouldRestart = true;
907
+ }
908
+ if (!shouldRestart) return;
909
+ log.warning("Restarting gateway. In-flight runs may be interrupted.");
910
+ const restartSpinner = spinner();
911
+ restartSpinner.start("openclaw gateway restart");
912
+ const restartResult = runOpenclaw(["gateway", "restart"], {
913
+ timeoutMs: 6e4,
914
+ env: envForSubprocess(paths)
915
+ });
916
+ if (!restartResult.ok) {
917
+ restartSpinner.stop("Gateway restart failed");
918
+ const detail = restartResult.stderr.trim() || restartResult.stdout.trim() || `exit code ${restartResult.code}`;
919
+ log.warning(`openclaw gateway restart failed: ${detail}. Restart it yourself when ready.`);
920
+ return;
921
+ }
922
+ restartSpinner.stop("Gateway restarted");
923
+ }
924
+ function normalizeUninstallFlags(flags) {
925
+ let restart = "auto";
926
+ if (flags["no-restart"] === true) restart = "never";
927
+ if (flags.restart === true) {
928
+ if (flags["no-restart"] === true) throw new Error("--restart and --no-restart are mutually exclusive");
929
+ restart = "force";
930
+ }
931
+ return {
932
+ noPrompt: flags["no-prompt"] === true || flags.yes === true,
933
+ openclawDir: typeof flags["openclaw-dir"] === "string" ? flags["openclaw-dir"] : void 0,
934
+ restart
935
+ };
936
+ }
937
+ async function runUninstall(flags = {}) {
938
+ intro(pc.bgYellow(pc.black(" Latitude · OpenClaw telemetry — uninstall ")));
939
+ const resolved = resolveConfigDir({ flag: flags.openclawDir });
940
+ const paths = pathsFor(resolved.dir);
941
+ log.info(`Using config dir: ${pc.dim(paths.configDir)} ${pc.dim(`(source: ${resolved.source})`)}`);
942
+ if (!hasLatitudePlugin(readSettings(paths.settingsPath))) {
943
+ note("No Latitude plugin entry found — nothing to remove.", "Status");
944
+ outro(pc.dim("Nothing changed"));
945
+ return;
946
+ }
947
+ note([
948
+ `Run \`openclaw plugins uninstall ${PLUGIN_ID} --force\` (removes files, install record, and plugin entry)`,
949
+ `Sweep any leftover LATITUDE_* keys from settings.env`,
950
+ `Backup of openclaw.json saved at ${paths.settingsBackupPath}`
951
+ ].join("\n"), "Plan");
952
+ if (!flags.noPrompt) {
953
+ if (process.stdin.isTTY !== true) throw new Error("Non-interactive uninstall requires --yes / --no-prompt to confirm. Re-run with --yes to bypass the prompt explicitly.");
954
+ const ok = await confirm({
955
+ message: "Proceed?",
956
+ initialValue: true
957
+ });
958
+ if (isCancel(ok) || ok !== true) return onCancel();
959
+ }
960
+ backupSettings(paths.settingsPath, paths.settingsBackupPath);
961
+ const s = spinner();
962
+ s.start("Reverting via openclaw plugins uninstall");
963
+ const uninstallResult = runOpenclaw([
964
+ "plugins",
965
+ "uninstall",
966
+ PLUGIN_ID,
967
+ "--force"
968
+ ], {
969
+ timeoutMs: 6e4,
970
+ env: envForSubprocess(paths)
971
+ });
972
+ if (!uninstallResult.ok) {
973
+ s.stop("openclaw plugins uninstall failed");
974
+ if (uninstallResult.reason === "enoent") log.warning("`openclaw` not found on PATH. Falling back to local cleanup — files at <configDir>/extensions/ may remain.");
975
+ else {
976
+ const detail = uninstallResult.stderr.trim() || uninstallResult.stdout.trim() || `exit code ${uninstallResult.code}`;
977
+ log.warning(`openclaw plugins uninstall reported: ${detail}. Continuing with local cleanup.`);
978
+ }
979
+ } else s.stop("Plugin removed by OpenClaw");
980
+ const cleanupSpinner = spinner();
981
+ cleanupSpinner.start("Reverting openclaw.json");
982
+ const post = readSettings(paths.settingsPath);
983
+ removePluginEntry(post);
984
+ removeFromPluginsAllow(post);
985
+ migrateLegacyEntries(post);
986
+ writeSettings(paths.settingsPath, post);
987
+ cleanupSpinner.stop("Done");
988
+ await maybeRestartGateway(flags.restart ?? "auto", paths);
989
+ outro(pc.green("✓ Uninstalled"));
990
+ }
991
+ //#endregion
992
+ //#region src/cli.ts
993
+ const USAGE = `usage: latitude-openclaw <command> [options]
994
+
995
+ commands:
996
+ install Install the plugin (interactive when stdin is a TTY)
997
+ uninstall Remove the plugin entry and files
998
+ --version, -v Print the package version
999
+ --help, -h Print this message
1000
+
1001
+ install options:
1002
+ --api-key=<key> Pass the API key non-interactively
1003
+ --project=<slug> Pass the project slug non-interactively
1004
+ --staging Target https://staging.latitude.so / staging-ingest
1005
+ --dev Target http://localhost:3000 / 3002
1006
+ --no-content Skip raw prompt/response/tool I/O capture
1007
+ --allow-conversation Force conversation capture on (overrides existing config)
1008
+ --no-trust Skip adding plugin id to plugins.allow
1009
+ --openclaw-dir=<path> Override OpenClaw config dir (default: $OPENCLAW_HOME, ./openclaw.json,
1010
+ or ~/.openclaw — see resolution order in README)
1011
+ --dry-run Show the diff against current openclaw.json and exit (no writes)
1012
+ --restart Always restart the gateway, even non-TTY
1013
+ --no-restart Never restart the gateway, even on TTY
1014
+ --yes / --no-prompt Skip all prompts (required for non-TTY / CI)
1015
+
1016
+ uninstall options:
1017
+ --openclaw-dir=<path> Override OpenClaw config dir (same precedence as install)
1018
+ --restart Always restart the gateway, even non-TTY
1019
+ --no-restart Never restart the gateway, even on TTY
1020
+ --yes / --no-prompt Skip the confirmation prompt
1021
+ `;
1022
+ async function main() {
1023
+ const argv = process.argv.slice(2);
1024
+ if (argv[0] === "--version" || argv[0] === "-v") {
1025
+ process.stdout.write(`${readCliVersion()}\n`);
1026
+ return;
1027
+ }
1028
+ if (argv[0] === "--help" || argv[0] === "-h") {
1029
+ process.stdout.write(USAGE);
1030
+ return;
1031
+ }
1032
+ const { subcommand, flags } = parseFlags(argv);
1033
+ if (subcommand === "install" || subcommand === void 0) {
1034
+ await runInstall(normalizeInstallFlags(flags));
1035
+ return;
1036
+ }
1037
+ if (subcommand === "uninstall") {
1038
+ await runUninstall(normalizeUninstallFlags(flags));
1039
+ return;
1040
+ }
1041
+ process.stderr.write(`unknown subcommand: ${subcommand}\n`);
1042
+ process.stderr.write(USAGE);
1043
+ process.exit(1);
1044
+ }
1045
+ main().catch((err) => {
1046
+ process.stderr.write(`${String(err)}\n`);
1047
+ process.exit(1);
1048
+ });
1049
+ //#endregion
1050
+ export {};
1051
+
1052
+ //# sourceMappingURL=cli.js.map