@latitude-data/openclaw-telemetry 0.0.2 → 0.0.4

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 CHANGED
@@ -1,15 +1,141 @@
1
1
  #!/usr/bin/env node
2
- import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { cancel, confirm, intro, isCancel, log, note, outro, password, spinner, text } from "@clack/prompts";
5
6
  import pc from "picocolors";
6
- import { fileURLToPath } from "node:url";
7
+ import { spawnSync } from "node:child_process";
7
8
  import { homedir } from "node:os";
9
+ //#region src/openclaw-cli.ts
10
+ /**
11
+ * Lowest OpenClaw version we support. The reporter verified hook-dispatch
12
+ * gating works correctly here; older versions either reject
13
+ * `hooks.allowConversationAccess` outright (≤ 2026.4.21) or have unverified
14
+ * gating behaviour (2026.4.22 – 2026.4.24). Refusing to install on older
15
+ * versions is intentional — we'd rather fail loudly than ship a
16
+ * config the gateway will quarantine or hooks the dispatcher will block.
17
+ */
18
+ const MIN_OPENCLAW_VERSION = "2026.4.25";
19
+ const DEFAULT_TIMEOUT_MS = 1e4;
20
+ /**
21
+ * Spawn `openclaw <args>` synchronously. Reports failure modes structurally so
22
+ * callers can decide how to degrade (missing binary vs. timed out vs. exited
23
+ * non-zero). Never throws; ENOENT becomes `{ reason: "enoent" }`.
24
+ */
25
+ function runOpenclaw(args, opts = {}) {
26
+ const result = spawnSync("openclaw", args, {
27
+ encoding: "utf-8",
28
+ timeout: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
29
+ input: opts.stdin,
30
+ stdio: [
31
+ "pipe",
32
+ "pipe",
33
+ "pipe"
34
+ ]
35
+ });
36
+ const err = result.error;
37
+ if (err?.code === "ENOENT") return {
38
+ ok: false,
39
+ reason: "enoent",
40
+ stdout: "",
41
+ stderr: "",
42
+ code: null
43
+ };
44
+ if (err?.code === "ETIMEDOUT" || result.signal === "SIGTERM" || result.signal === "SIGKILL") return {
45
+ ok: false,
46
+ reason: "timeout",
47
+ stdout: result.stdout ?? "",
48
+ stderr: result.stderr ?? "",
49
+ code: null
50
+ };
51
+ if (err) return {
52
+ ok: false,
53
+ reason: "exit",
54
+ stdout: result.stdout ?? "",
55
+ stderr: result.stderr ?? String(err),
56
+ code: typeof result.status === "number" ? result.status : 1
57
+ };
58
+ if (result.status === 0) return {
59
+ ok: true,
60
+ stdout: result.stdout ?? "",
61
+ stderr: result.stderr ?? "",
62
+ code: 0
63
+ };
64
+ return {
65
+ ok: false,
66
+ reason: "exit",
67
+ stdout: result.stdout ?? "",
68
+ stderr: result.stderr ?? "",
69
+ code: typeof result.status === "number" ? result.status : 1
70
+ };
71
+ }
72
+ /**
73
+ * Run `openclaw --version` and parse out the version string.
74
+ *
75
+ * Banner format (per OpenClaw `src/cli/banner.ts` `formatCliBannerLine`):
76
+ * `🦞 OpenClaw <version> (<commit-sha>)`
77
+ *
78
+ * The banner is normally suppressed when `--version` is the flag, but the
79
+ * version itself still goes to stdout. We accept either layout (with or
80
+ * without the lobster + commit sha) so we don't break if OpenClaw later
81
+ * prints just the bare version string.
82
+ */
83
+ function getOpenclawVersion() {
84
+ const result = runOpenclaw(["--version"], { timeoutMs: 5e3 });
85
+ if (!result.ok) {
86
+ if (result.reason === "enoent") return {
87
+ ok: false,
88
+ error: "missing"
89
+ };
90
+ return {
91
+ ok: false,
92
+ error: "unparseable",
93
+ raw: result.stdout || result.stderr
94
+ };
95
+ }
96
+ const raw = result.stdout.trim();
97
+ const match = raw.match(/(\d{4}\.\d+\.\d+)/);
98
+ if (!match) return {
99
+ ok: false,
100
+ error: "unparseable",
101
+ raw
102
+ };
103
+ return {
104
+ ok: true,
105
+ version: match[1],
106
+ raw
107
+ };
108
+ }
109
+ /**
110
+ * Compare two CalVer strings (`YYYY.M.PATCH`). Returns -1 if `a` < `b`,
111
+ * 0 if equal, 1 if `a` > `b`. Strings with extra components or non-numeric
112
+ * pieces fall back to a per-component string comparison so unexpected
113
+ * formats don't crash the installer.
114
+ */
115
+ function compareCalver(a, b) {
116
+ const ap = a.split(".");
117
+ const bp = b.split(".");
118
+ const len = Math.max(ap.length, bp.length);
119
+ for (let i = 0; i < len; i++) {
120
+ const ai = ap[i] ?? "0";
121
+ const bi = bp[i] ?? "0";
122
+ const an = Number(ai);
123
+ const bn = Number(bi);
124
+ if (Number.isFinite(an) && Number.isFinite(bn)) {
125
+ if (an < bn) return -1;
126
+ if (an > bn) return 1;
127
+ continue;
128
+ }
129
+ if (ai < bi) return -1;
130
+ if (ai > bi) return 1;
131
+ }
132
+ return 0;
133
+ }
134
+ //#endregion
8
135
  //#region src/settings-file.ts
9
136
  const CONFIG_DIR = join(homedir(), ".openclaw");
10
137
  const SETTINGS_PATH = join(CONFIG_DIR, "openclaw.json");
11
138
  const SETTINGS_BACKUP_PATH = join(CONFIG_DIR, "openclaw.json.latitude-bak");
12
- const PLUGIN_INSTALL_DIR = join(join(CONFIG_DIR, "extensions"), "latitude-telemetry");
13
139
  /** Plugin id used both as the npm package name and as the OpenClaw plugin id. */
14
140
  const PLUGIN_ID = "@latitude-data/openclaw-telemetry";
15
141
  function readSettings() {
@@ -29,22 +155,38 @@ function backupSettings() {
29
155
  if (existsSync(SETTINGS_PATH)) copyFileSync(SETTINGS_PATH, SETTINGS_BACKUP_PATH);
30
156
  }
31
157
  /**
32
- * Set the `plugins.entries[id]` block for our plugin. Writes credentials and
33
- * options into the `.config` bucket — never under `hooks` (strict zod) and
34
- * never as top-level `env` keys (root schema rejects them).
158
+ * Set the `plugins.entries[id]` block for our plugin.
159
+ *
160
+ * Two places in the entry get written:
161
+ *
162
+ * - `.config` (free-form `record(string, unknown)`): credentials, baseUrl,
163
+ * and our copy of `allowConversationAccess`. This is what the plugin
164
+ * runtime reads via `api.pluginConfig`.
165
+ * - `.hooks.allowConversationAccess`: controls whether OpenClaw's hook
166
+ * dispatcher actually forwards `llm_input` / `llm_output` / tool /
167
+ * `agent_end` events to our handlers. Without it set to `true` on
168
+ * OpenClaw 2026.4.25+, every typed hook is blocked at the dispatcher
169
+ * and the plugin's handlers never fire.
170
+ *
171
+ * The two flags mean different things — `hooks.*` is the dispatch gate,
172
+ * `config.*` is the payload-content gate — but for THIS plugin we always
173
+ * couple them: dispatch off + payload on is useless (no payloads to gate),
174
+ * and dispatch on + payload off is a legitimate "structural-only telemetry"
175
+ * mode (timing, tokens, ids, agent name; no message bodies). Always writing
176
+ * both from the same source keeps the operator's mental model simple.
35
177
  *
36
178
  * Re-install idempotency: only `apiKey` / `project` / `baseUrl` always
37
- * overwrite (these come from the install prompts). `enabled`, `debug`, and
38
- * `allowConversationAccess` are preserved when not provided in the patch,
39
- * so a user who hand-edited `enabled: false` or `debug: true` doesn't lose
40
- * their choice on a re-install.
179
+ * overwrite (these come from install prompts). `enabled`, `debug`, and
180
+ * `allowConversationAccess` are preserved when not provided in the patch.
41
181
  */
42
182
  function setPluginEntry(settings, patch) {
43
183
  const plugins = settings.plugins ?? {};
44
184
  const entries = plugins.entries ?? {};
45
185
  const existing = entries["@latitude-data/openclaw-telemetry"] ?? {};
186
+ const existingConfig = existing.config ?? {};
187
+ const existingHooks = existing.hooks ?? {};
46
188
  const nextConfig = {
47
- ...existing.config ?? {},
189
+ ...existingConfig,
48
190
  apiKey: patch.apiKey,
49
191
  project: patch.project
50
192
  };
@@ -52,16 +194,22 @@ function setPluginEntry(settings, patch) {
52
194
  else delete nextConfig.baseUrl;
53
195
  if (patch.allowConversationAccess !== void 0) nextConfig.allowConversationAccess = patch.allowConversationAccess;
54
196
  if (patch.debug !== void 0) nextConfig.debug = patch.debug;
197
+ const effectiveAccess = typeof nextConfig.allowConversationAccess === "boolean" ? nextConfig.allowConversationAccess : typeof existingHooks.allowConversationAccess === "boolean" ? existingHooks.allowConversationAccess : true;
198
+ const nextHooks = {
199
+ ...existingHooks,
200
+ allowConversationAccess: effectiveAccess
201
+ };
55
202
  const nextEnabled = patch.enabled ?? existing.enabled ?? true;
56
203
  entries[PLUGIN_ID] = {
57
204
  ...existing,
58
205
  enabled: nextEnabled,
206
+ hooks: nextHooks,
59
207
  config: nextConfig
60
208
  };
61
209
  plugins.entries = entries;
62
210
  settings.plugins = plugins;
63
211
  }
64
- /** Remove the plugin entry entirely. */
212
+ /** Remove the plugin entry entirely. Used by uninstall as defense-in-depth. */
65
213
  function removePluginEntry(settings) {
66
214
  const plugins = settings.plugins;
67
215
  if (!plugins?.entries) return false;
@@ -69,26 +217,58 @@ function removePluginEntry(settings) {
69
217
  delete plugins.entries[PLUGIN_ID];
70
218
  return true;
71
219
  }
220
+ /**
221
+ * Add the plugin id to `plugins.allow`. Idempotent — returns `true` only when
222
+ * the array changed. OpenClaw warns at every gateway start when a non-bundled
223
+ * plugin auto-loads without provenance via `plugins.allow` or an install
224
+ * record. We get one warning cleared by going through `openclaw plugins
225
+ * install` (provenance) and the other by adding ourselves to allow.
226
+ *
227
+ * Defensive against hand-edited non-array values: if `plugins.allow` is
228
+ * present but not an array (e.g. someone wrote a string), we replace it
229
+ * with a single-element array rather than spreading the bad value.
230
+ */
231
+ function addToPluginsAllow(settings) {
232
+ const plugins = settings.plugins ?? {};
233
+ const existing = plugins.allow;
234
+ const allow = Array.isArray(existing) ? existing : [];
235
+ if (allow.includes("@latitude-data/openclaw-telemetry")) return false;
236
+ plugins.allow = [...allow, PLUGIN_ID];
237
+ settings.plugins = plugins;
238
+ return true;
239
+ }
240
+ /**
241
+ * Inverse of `addToPluginsAllow`. Defense-in-depth — `openclaw plugins
242
+ * uninstall` already strips the entry, but the install path can be skipped
243
+ * (e.g. the user removed the plugin manually) and we want re-install/uninstall
244
+ * round-trips to be tidy regardless.
245
+ */
246
+ function removeFromPluginsAllow(settings) {
247
+ const allow = settings.plugins?.allow;
248
+ if (!Array.isArray(allow) || !allow.includes("@latitude-data/openclaw-telemetry")) return false;
249
+ if (settings.plugins) settings.plugins.allow = allow.filter((id) => id !== PLUGIN_ID);
250
+ return true;
251
+ }
72
252
  function hasLatitudePlugin(settings) {
73
253
  return Boolean(settings.plugins?.entries && "@latitude-data/openclaw-telemetry" in settings.plugins.entries);
74
254
  }
75
255
  /**
76
- * Strip any leftover top-level keys our older installer (<= 0.0.1) wrote. The
77
- * 0.0.1 installer wrote `hooks.allowConversationAccess` (rejected by strict
78
- * zod) and `LATITUDE_*` keys at root-level `env` (also rejected). Both are
79
- * cleaned up here so re-running install lands a config OpenClaw will validate.
256
+ * Strip leftover keys from older installers that the strict zod schema
257
+ * rejects on current OpenClaw versions.
258
+ *
259
+ * 0.0.1 wrote `LATITUDE_*` keys directly under `settings.env`. OpenClaw's
260
+ * root schema is strict; the `env` block accepts only `{shellEnv, vars}`,
261
+ * so those keys cause the gateway to quarantine the config as
262
+ * `clobbered.<ts>` and roll back. We sweep them on every install.
263
+ *
264
+ * Note: 0.0.1 also wrote `hooks.allowConversationAccess` (when that key was
265
+ * not yet in the schema). We deliberately do NOT strip it anymore — on
266
+ * OpenClaw 2026.4.25+ the key IS in the schema and IS load-bearing for
267
+ * dispatch. `setPluginEntry` overwrites it on every install with the right
268
+ * value, so any 0.0.1 leftover is reconciled there.
80
269
  */
81
270
  function migrateLegacyEntries(settings) {
82
271
  let changed = false;
83
- const entry = settings.plugins?.entries?.[PLUGIN_ID];
84
- if (entry && typeof entry === "object") {
85
- const hooks = entry.hooks;
86
- if (hooks && typeof hooks === "object" && "allowConversationAccess" in hooks) {
87
- delete hooks.allowConversationAccess;
88
- if (Object.keys(hooks).length === 0) delete entry.hooks;
89
- changed = true;
90
- }
91
- }
92
272
  const env = settings.env;
93
273
  if (env && typeof env === "object" && !Array.isArray(env)) {
94
274
  const envObj = env;
@@ -105,62 +285,6 @@ function migrateLegacyEntries(settings) {
105
285
  return { changed };
106
286
  }
107
287
  //#endregion
108
- //#region src/install-files.ts
109
- /**
110
- * Copies the plugin's runtime files into `~/.openclaw/extensions/latitude-telemetry/`
111
- * so OpenClaw's discovery (`<configDir>/extensions/<plugin>`) picks it up.
112
- *
113
- * `npx -y @latitude-data/openclaw-telemetry` runs the CLI from a temporary npm
114
- * location — relying on that path persisting across runs (or even across the
115
- * gateway restart that follows install) is unsafe. So we materialize a stable
116
- * copy under the user's OpenClaw config dir, the same path OpenClaw scans on
117
- * startup. Layout we produce:
118
- *
119
- * ~/.openclaw/extensions/latitude-telemetry/
120
- * openclaw.plugin.json <- the manifest, required by discovery
121
- * package.json <- minimal — keeps node module-resolution happy
122
- * dist/ <- compiled plugin entrypoint(s)
123
- */
124
- function installPluginFiles() {
125
- const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
126
- const manifestSrc = join(packageRoot, "openclaw.plugin.json");
127
- const distSrc = join(packageRoot, "dist");
128
- const pkgSrc = join(packageRoot, "package.json");
129
- if (!existsSync(manifestSrc)) throw new Error(`Cannot install: missing openclaw.plugin.json at ${manifestSrc}. This is a packaging bug — please file an issue.`);
130
- if (!existsSync(distSrc)) throw new Error(`Cannot install: missing compiled dist at ${distSrc}.`);
131
- if (existsSync(PLUGIN_INSTALL_DIR)) rmSync(PLUGIN_INSTALL_DIR, {
132
- recursive: true,
133
- force: true
134
- });
135
- mkdirSync(PLUGIN_INSTALL_DIR, { recursive: true });
136
- copyFileSync(manifestSrc, join(PLUGIN_INSTALL_DIR, "openclaw.plugin.json"));
137
- cpSync(distSrc, join(PLUGIN_INSTALL_DIR, "dist"), { recursive: true });
138
- if (existsSync(pkgSrc)) {
139
- const sourcePkg = JSON.parse(readFileSync(pkgSrc, "utf-8"));
140
- const minimalPkg = {
141
- name: sourcePkg.name,
142
- version: sourcePkg.version,
143
- type: sourcePkg.type ?? "module",
144
- main: sourcePkg.main ?? "./dist/plugin.js",
145
- private: true
146
- };
147
- writeFileSync(join(PLUGIN_INSTALL_DIR, "package.json"), `${JSON.stringify(minimalPkg, null, 2)}\n`, "utf-8");
148
- }
149
- return {
150
- destination: PLUGIN_INSTALL_DIR,
151
- entryPath: join(PLUGIN_INSTALL_DIR, "dist", "plugin.js")
152
- };
153
- }
154
- /** Remove the materialized plugin directory under `~/.openclaw/extensions/`. */
155
- function removePluginFiles() {
156
- if (!existsSync(PLUGIN_INSTALL_DIR)) return false;
157
- rmSync(PLUGIN_INSTALL_DIR, {
158
- recursive: true,
159
- force: true
160
- });
161
- return true;
162
- }
163
- //#endregion
164
288
  //#region src/setup.ts
165
289
  const DOCS_URL = "https://docs.latitude.so/openclaw-telemetry";
166
290
  const PRODUCTION_ENV = {
@@ -217,6 +341,7 @@ function normalizeInstallFlags(flags) {
217
341
  project: typeof flags.project === "string" ? flags.project : void 0,
218
342
  environment,
219
343
  allowConversationAccess,
344
+ noTrust: flags["no-trust"] === true,
220
345
  noPrompt: flags["no-prompt"] === true || flags.yes === true,
221
346
  yes: flags.yes === true
222
347
  };
@@ -227,6 +352,7 @@ async function runInstall(flags = {}) {
227
352
  }
228
353
  async function runInteractiveInstall(flags) {
229
354
  intro(pc.bgCyan(pc.black(" Latitude · OpenClaw telemetry ")));
355
+ ensureOpenclawIsCompatible();
230
356
  const existingConfig = readSettings().plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? void 0;
231
357
  const envConfig = flags.environment ?? PRODUCTION_ENV;
232
358
  const urls = urlsFor(envConfig);
@@ -247,7 +373,8 @@ async function runInteractiveInstall(flags) {
247
373
  apiKey,
248
374
  project,
249
375
  envConfig,
250
- allowConversationAccess: flags.allowConversationAccess
376
+ allowConversationAccess: flags.allowConversationAccess,
377
+ noTrust: flags.noTrust === true
251
378
  });
252
379
  note([
253
380
  "Restart the OpenClaw gateway for the plugin to load:",
@@ -258,6 +385,7 @@ async function runInteractiveInstall(flags) {
258
385
  outro(pc.green("✓ Installed"));
259
386
  }
260
387
  async function runFlagDrivenInstall(flags) {
388
+ ensureOpenclawIsCompatible();
261
389
  const apiKey = flags.apiKey;
262
390
  const project = flags.project;
263
391
  if (!apiKey || !project) throw new Error("Non-interactive install requires --api-key=... and --project=... (or run in a TTY).");
@@ -265,10 +393,10 @@ async function runFlagDrivenInstall(flags) {
265
393
  apiKey,
266
394
  project,
267
395
  envConfig: flags.environment ?? PRODUCTION_ENV,
268
- allowConversationAccess: flags.allowConversationAccess
396
+ allowConversationAccess: flags.allowConversationAccess,
397
+ noTrust: flags.noTrust === true
269
398
  });
270
399
  process.stdout.write(`Installed Latitude plugin in ${SETTINGS_PATH}\n`);
271
- process.stdout.write(`Plugin files at ${PLUGIN_INSTALL_DIR}\n`);
272
400
  }
273
401
  async function promptApiKey(_existing, flag) {
274
402
  if (flag) return flag;
@@ -295,48 +423,92 @@ function onCancel() {
295
423
  cancel("Cancelled — nothing was changed");
296
424
  process.exit(1);
297
425
  }
298
- async function applyChanges({ apiKey, project, envConfig, allowConversationAccess }) {
299
- const filesSpinner = spinner();
300
- filesSpinner.start("Installing plugin files");
301
- const { destination } = installPluginFiles();
302
- filesSpinner.stop(`Plugin files installed at ${destination}`);
303
- const settingsSpinner = spinner();
304
- settingsSpinner.start("Updating openclaw.json");
426
+ async function applyChanges({ apiKey, project, envConfig, allowConversationAccess, noTrust }) {
305
427
  ensureSettingsDir();
306
428
  backupSettings();
429
+ const packageRoot = resolvePackageRoot();
430
+ const installSpinner = spinner();
431
+ installSpinner.start(`Installing plugin via openclaw plugins install ${packageRoot}`);
432
+ const installResult = runOpenclaw([
433
+ "plugins",
434
+ "install",
435
+ packageRoot,
436
+ "--force"
437
+ ], { timeoutMs: 6e4 });
438
+ if (!installResult.ok) {
439
+ installSpinner.stop("openclaw plugins install failed");
440
+ if (installResult.reason === "enoent") throw new Error("`openclaw` not found on PATH. Install OpenClaw first (https://openclaw.ai/install) and re-run.");
441
+ if (installResult.reason === "timeout") throw new Error("openclaw plugins install timed out after 60s. Try running it manually to see what's stuck.");
442
+ const detail = installResult.stderr.trim() || installResult.stdout.trim() || `exit code ${installResult.code}`;
443
+ throw new Error(`openclaw plugins install failed: ${detail}`);
444
+ }
445
+ installSpinner.stop("Plugin registered with OpenClaw");
446
+ const settingsSpinner = spinner();
447
+ settingsSpinner.start("Updating openclaw.json");
307
448
  const settings = readSettings();
308
449
  migrateLegacyEntries(settings);
309
- const existingConfig = settings.plugins?.entries?.["@latitude-data/openclaw-telemetry"]?.config ?? {};
310
- const finalAllowConversationAccess = allowConversationAccess ?? existingConfig.allowConversationAccess ?? true;
311
450
  setPluginEntry(settings, {
312
451
  apiKey,
313
452
  project,
314
453
  baseUrl: envConfig.name === "production" ? void 0 : envConfig.ingest,
315
- allowConversationAccess: finalAllowConversationAccess
454
+ allowConversationAccess
316
455
  });
456
+ if (!noTrust) addToPluginsAllow(settings);
317
457
  writeSettings(settings);
318
458
  settingsSpinner.stop(`Updated ${SETTINGS_PATH}`);
319
459
  if (existsSync(SETTINGS_BACKUP_PATH)) log.info(`Backup saved at ${pc.dim(SETTINGS_BACKUP_PATH)}`);
460
+ 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.`);
320
461
  }
321
462
  function ensureSettingsDir() {
322
463
  const dir = dirname(SETTINGS_PATH);
323
464
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
324
465
  }
466
+ /**
467
+ * Verify `openclaw` is on PATH AND its version is >= MIN_OPENCLAW_VERSION.
468
+ * Aborts with a clear upgrade message otherwise. Called before any user
469
+ * prompts so we don't waste their time collecting credentials we can't
470
+ * use.
471
+ */
472
+ function ensureOpenclawIsCompatible() {
473
+ const v = getOpenclawVersion();
474
+ if (!v.ok) {
475
+ if (v.error === "missing") {
476
+ cancel("OpenClaw CLI not found on PATH. Install or update via `npm install -g openclaw@latest` and re-run.");
477
+ process.exit(1);
478
+ }
479
+ cancel(`Couldn't parse OpenClaw version output${v.raw ? ` (got: ${pc.dim(v.raw)})` : ""}. Run \`openclaw --version\` and report the output.`);
480
+ process.exit(1);
481
+ }
482
+ if (compareCalver(v.version, "2026.4.25") < 0) {
483
+ cancel(`OpenClaw ${v.version} is older than the minimum supported version (${MIN_OPENCLAW_VERSION}). Run \`npm install -g openclaw@latest\` and re-run install.`);
484
+ process.exit(1);
485
+ }
486
+ log.info(`OpenClaw ${pc.dim(v.version)} (>= ${MIN_OPENCLAW_VERSION})`);
487
+ }
488
+ /**
489
+ * Resolve the absolute path to our package's root (the directory that
490
+ * contains `package.json` + `openclaw.plugin.json` + `dist/`). The compiled
491
+ * CLI lives at `<package-root>/dist/cli.js`, so `import.meta.url`'s parent
492
+ * directory's parent is our root.
493
+ *
494
+ * `openclaw plugins install <path>` copies files synchronously, so we
495
+ * don't have to keep this directory alive past the spawn return.
496
+ */
497
+ function resolvePackageRoot() {
498
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
499
+ }
325
500
  async function runUninstall(flags = {}) {
326
501
  intro(pc.bgYellow(pc.black(" Latitude · OpenClaw telemetry — uninstall ")));
327
- const settings = readSettings();
328
- const hasEntry = hasLatitudePlugin(settings);
329
- const hasFiles = existsSync(PLUGIN_INSTALL_DIR);
330
- if (!hasEntry && !hasFiles) {
331
- note("No Latitude plugin entry or files found — nothing to remove.", "Status");
502
+ if (!hasLatitudePlugin(readSettings())) {
503
+ note("No Latitude plugin entry found — nothing to remove.", "Status");
332
504
  outro(pc.dim("Nothing changed"));
333
505
  return;
334
506
  }
335
507
  note([
336
- hasEntry ? `Remove "${PLUGIN_ID}" plugin entry from ${SETTINGS_PATH}` : null,
337
- hasFiles ? `Delete plugin files at ${PLUGIN_INSTALL_DIR}` : null,
508
+ `Run \`openclaw plugins uninstall ${PLUGIN_ID} --force\` (removes files, install record, and plugin entry)`,
509
+ `Sweep any leftover LATITUDE_* keys from settings.env`,
338
510
  `Backup of openclaw.json saved at ${SETTINGS_BACKUP_PATH}`
339
- ].filter(Boolean).join("\n"), "Plan");
511
+ ].join("\n"), "Plan");
340
512
  if (!flags.noPrompt && process.stdin.isTTY === true) {
341
513
  const ok = await confirm({
342
514
  message: "Proceed?",
@@ -344,22 +516,71 @@ async function runUninstall(flags = {}) {
344
516
  });
345
517
  if (isCancel(ok) || ok !== true) return onCancel();
346
518
  }
519
+ backupSettings();
347
520
  const s = spinner();
348
- s.start("Reverting settings");
349
- if (hasEntry) {
350
- backupSettings();
351
- removePluginEntry(settings);
352
- migrateLegacyEntries(settings);
353
- writeSettings(settings);
354
- }
355
- if (hasFiles) removePluginFiles();
356
- s.stop("Done");
521
+ s.start("Reverting via openclaw plugins uninstall");
522
+ const uninstallResult = runOpenclaw([
523
+ "plugins",
524
+ "uninstall",
525
+ PLUGIN_ID,
526
+ "--force"
527
+ ], { timeoutMs: 6e4 });
528
+ if (!uninstallResult.ok) {
529
+ s.stop("openclaw plugins uninstall failed");
530
+ if (uninstallResult.reason === "enoent") log.warning("`openclaw` not found on PATH. Falling back to local cleanup — files at ~/.openclaw/extensions/ may remain.");
531
+ else {
532
+ const detail = uninstallResult.stderr.trim() || uninstallResult.stdout.trim() || `exit code ${uninstallResult.code}`;
533
+ log.warning(`openclaw plugins uninstall reported: ${detail}. Continuing with local cleanup.`);
534
+ }
535
+ } else s.stop("Plugin removed by OpenClaw");
536
+ const cleanupSpinner = spinner();
537
+ cleanupSpinner.start("Reverting openclaw.json");
538
+ const post = readSettings();
539
+ removePluginEntry(post);
540
+ removeFromPluginsAllow(post);
541
+ migrateLegacyEntries(post);
542
+ writeSettings(post);
543
+ cleanupSpinner.stop("Done");
357
544
  outro(pc.green("✓ Uninstalled"));
358
545
  }
359
546
  //#endregion
360
547
  //#region src/cli.ts
548
+ const USAGE = `usage: latitude-openclaw <command> [options]
549
+
550
+ commands:
551
+ install Install the plugin (interactive when stdin is a TTY)
552
+ uninstall Remove the plugin entry and files
553
+ --version, -v Print the package version
554
+ --help, -h Print this message
555
+
556
+ install options:
557
+ --api-key=<key> Pass the API key non-interactively
558
+ --project=<slug> Pass the project slug non-interactively
559
+ --staging Target https://staging.latitude.so / staging-ingest
560
+ --dev Target http://localhost:3000 / 3002
561
+ --no-content Skip raw prompt/response/tool I/O capture
562
+ --allow-conversation Force conversation capture on (overrides existing config)
563
+ --yes / --no-prompt Skip all prompts (required for non-TTY / CI)
564
+ `;
565
+ function readVersion() {
566
+ const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
567
+ try {
568
+ return JSON.parse(readFileSync(pkgPath, "utf-8")).version ?? "unknown";
569
+ } catch {
570
+ return "unknown";
571
+ }
572
+ }
361
573
  async function main() {
362
- const { subcommand, flags } = parseFlags(process.argv.slice(2));
574
+ const argv = process.argv.slice(2);
575
+ if (argv[0] === "--version" || argv[0] === "-v") {
576
+ process.stdout.write(`${readVersion()}\n`);
577
+ return;
578
+ }
579
+ if (argv[0] === "--help" || argv[0] === "-h") {
580
+ process.stdout.write(USAGE);
581
+ return;
582
+ }
583
+ const { subcommand, flags } = parseFlags(argv);
363
584
  if (subcommand === "install" || subcommand === void 0) {
364
585
  await runInstall(normalizeInstallFlags(flags));
365
586
  return;
@@ -369,7 +590,7 @@ async function main() {
369
590
  return;
370
591
  }
371
592
  process.stderr.write(`unknown subcommand: ${subcommand}\n`);
372
- process.stderr.write("usage: latitude-openclaw [install|uninstall] [--api-key=...] [--project=...] [--staging|--dev] [--yes]\n");
593
+ process.stderr.write(USAGE);
373
594
  process.exit(1);
374
595
  }
375
596
  main().catch((err) => {