import type { OpenClawPluginApi } from "openclaw/plugin-sdk"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk"; import { existsSync, readFileSync, writeFileSync, appendFileSync, rmSync, realpathSync, mkdirSync, cpSync } from "fs"; import { createHash } from "crypto"; import { join, resolve, dirname, isAbsolute } from "path"; import { homedir } from "os"; import { execSync, spawn, spawnSync } from "child_process"; import { createRequire } from "module"; import { runSetup, type SetupContext, type CursorModel, detectCursorPath, detectOutputFormat, discoverCursorModels } from "./src/setup.js"; import { runDoctorChecks, formatDoctorResults, countDiscoveredTools } from "./src/doctor.js"; import { PLUGIN_ID, PROVIDER_ID, DEFAULT_PROXY_PORT, parseProxyPort, OPENCLAW_CONFIG_PATH, OPENCLAW_LOGS_DIR, CURSOR_PROXY_PID_PATH, CURSOR_PROXY_LOG_PATH, CURSOR_PROXY_STDERR_LOG_PATH, getCursorMcpConfigPath, type OutputFormat } from "./src/constants.js"; let proxyChild: ReturnType | null = null; let proxyRestartCount = 0; let lastProxyStartTime = 0; let healthCheckTimer: ReturnType | null = null; /** Ensures only one startProxy run at a time (no concurrent kill+spawn from register + health + exit). */ let startProxyInProgress = false; /** So we only show "gateway restart" outro once per process (avoid duplicate when both runInteractiveSetupInProcess and setup command run). */ let gatewayRestartOutroShown = false; const MAX_PROXY_RESTARTS = 3; const PROXY_RESTART_DELAYS = [2000, 10000, 60000]; const PROXY_STABLE_PERIOD = 300000; const HEALTH_CHECK_INTERVAL = 60000; // 60s // @clack/prompts lives in openclaw's node_modules; follow the bin symlink to resolve let _clack: any; function loadClack() { if (_clack) return _clack; let entry = process.argv[1] || __filename; try { entry = realpathSync(entry); } catch {} _clack = createRequire(entry)("@clack/prompts"); return _clack; } function fetchProxyHealth(port: number, timeoutMs = 5000): Record | null { try { const cmd = process.platform === "win32" ? `node -e "fetch('http://127.0.0.1:${port}/v1/health').then(r=>r.text()).then(t=>process.stdout.write(t)).catch(()=>process.stdout.write('{}'))"` : `curl -sf http://127.0.0.1:${port}/v1/health`; const raw = execSync(cmd, { encoding: "utf-8", timeout: timeoutMs, stdio: ["pipe", "pipe", "pipe"] }); return JSON.parse(raw); } catch { return null; } } function isProxyRunning(port: number): boolean { return fetchProxyHealth(port) !== null; } /** True if any process is listening on the port (lsof/netstat). More reliable than isProxyRunning when health check times out. */ function portHasProcess(port: number): boolean { try { if (process.platform === "win32") { const out = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim(); return out.length > 0; } const out = execSync(`lsof -ti :${port}`, { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] }).trim(); return out.length > 0; } catch { return false; } } /** Kill one PID via system kill/taskkill in a subprocess. Used when running inside gateway daemon where process.kill may be restricted. */ function killPidBySubprocess(pid: number): void { if (!Number.isFinite(pid) || pid <= 0) return; try { if (process.platform === "win32") { execSync(`taskkill /F /PID ${pid}`, { encoding: "utf-8", stdio: "pipe", timeout: 5000 }); } else { spawnSync("kill", ["-9", String(pid)], { stdio: "pipe", timeout: 5000 }); } } catch {} } /** @param signal Default SIGTERM; use "SIGKILL" to force-immediate exit (releases port without graceful shutdown). */ function killPortProcess(port: number, signal: "SIGTERM" | "SIGKILL" = "SIGTERM") { try { if (process.platform === "win32") { const out = execSync(`netstat -ano | findstr :${port} | findstr LISTENING`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim(); const pids = new Set(out.split("\n").map(l => l.trim().split(/\s+/).pop()).filter(Boolean)); for (const pid of pids) { const n = parseInt(pid!, 10); if (Number.isFinite(n)) killPidBySubprocess(n); } } else { const out = execSync(`lsof -ti :${port}`, { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] }).trim(); if (out) { for (const pid of out.split("\n")) { const n = parseInt(pid, 10); if (Number.isFinite(n)) killPidBySubprocess(n); } } } } catch {} } function computeFileHash(filePath: string): string { try { const content = readFileSync(filePath, "utf-8"); return createHash("sha256").update(content).digest("hex").slice(0, 12); } catch { return "unknown"; } } /** Run interactive setup (model selection + plugin config) in the same process during install. Blocks until user completes; re-applies in setImmediate after core may overwrite. */ async function runInteractiveSetupInProcess(opts: { pluginDir: string; config: Record; pluginConfig: Record; result: { cursorPath: string; cursorModels: CursorModel[]; outputFormat: OutputFormat }; proxyPort: number; }): Promise { const clack = loadClack(); clack.intro(`Cursor Brain Setup (v${readPackageVersion(opts.pluginDir)})`); const currentModel = (opts.config.agents as any)?.defaults?.model; const curPrimary = currentModel?.primary?.replace(`${PROVIDER_ID}/`, ""); const curFallbacks = (currentModel?.fallbacks as string[] | undefined)?.map((f: string) => f.replace(`${PROVIDER_ID}/`, "")); const selection = await promptModelSelection(opts.result.cursorModels, curPrimary, curFallbacks); if (selection) { try { saveModelSelection(selection.primary, selection.fallbacks, opts.proxyPort, opts.result.cursorModels); clack.log.success("Model configuration saved to openclaw.json (agents.defaults.model + providers)"); } catch (e: any) { clack.log.error(`Could not save model config: ${e?.message ?? String(e)}`); } } const configResult = await promptPluginConfig(opts.pluginConfig as Record); if (configResult) { try { mergePluginConfig(configResult); clack.log.success("Plugin configuration saved to openclaw.json"); } catch (e: any) { clack.log.error(`Could not save config: ${e?.message ?? String(e)}`); } } try { syncPluginInstallRecord({ installPath: opts.pluginDir, updateTimestamp: false, preservedConfig: configResult ?? undefined }); } catch {} const savedSelection = selection; const savedPluginConfig = configResult; setImmediate(() => { fixInstallRecordSourceOnDisk(opts.pluginDir); if (savedSelection) saveModelSelection(savedSelection.primary, savedSelection.fallbacks, opts.proxyPort, opts.result.cursorModels); if (savedPluginConfig) mergePluginConfig(savedPluginConfig); }); if (!gatewayRestartOutroShown) { gatewayRestartOutroShown = true; clack.outro("Run `openclaw gateway restart` (or restart your gateway) to apply changes."); } } function readPackageVersion(dir: string): string { try { return JSON.parse(readFileSync(join(dir, "package.json"), "utf-8")).version || "unknown"; } catch { return "unknown"; } } /** Returns 1 if a > b, -1 if a < b, 0 if equal. Non-semver strings compare as equal. */ function compareSemver(a: string, b: string): number { const pa = a.split(".").map(Number); const pb = b.split(".").map(Number); if (pa.some(isNaN) || pb.some(isNaN)) return 0; for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const na = pa[i] ?? 0; const nb = pb[i] ?? 0; if (na > nb) return 1; if (na < nb) return -1; } return 0; } /** * Keys that must not be stored in plugin config (single source is agents.defaults.model + models.providers). * Rule: plugins.entries[PLUGIN_ID].config only stores keys that are the single source of truth and directly used; no duplication of data that lives elsewhere. */ const PLUGIN_CONFIG_EXCLUDE_KEYS = new Set(["model", "fallbackModel"]); /** Build minimal env for proxy child to avoid spreading full process.env (reduces security-scan "env + network" warning). */ function buildProxyChildEnv(vars: { CURSOR_PATH: string; CURSOR_WORKSPACE_DIR: string; CURSOR_PROXY_PORT: string; CURSOR_OUTPUT_FORMAT: string; CURSOR_PROXY_SCRIPT_HASH: string; }): NodeJS.ProcessEnv { const e = process.env; const env: NodeJS.ProcessEnv = { PATH: e.PATH ?? "", HOME: e.HOME ?? e.USERPROFILE ?? "", USER: e.USER ?? e.USERNAME ?? "", LANG: e.LANG ?? "en_US.UTF-8", OPENCLAW_CONFIG_PATH: OPENCLAW_CONFIG_PATH, ...vars, }; if (process.platform === "win32") { env.USERPROFILE = e.USERPROFILE ?? env.HOME; env.SYSTEMROOT = e.SYSTEMROOT ?? ""; env.TEMP = e.TEMP ?? e.TMP ?? ""; } return env; } /** * Single entry point to start the streaming proxy. Serialized: only one run at a time. * Flow: kill our child (if any) → kill any process on port → wait until port free → spawn. */ function startProxy(opts: { pluginDir: string; cursorPath: string; workspaceDir: string; port: number; outputFormat: OutputFormat; logger: any; }) { if (startProxyInProgress) return; startProxyInProgress = true; const proxyScript = join(opts.pluginDir, "mcp-server", "streaming-proxy.mjs"); if (!existsSync(proxyScript)) { startProxyInProgress = false; return; } if (proxyChild) { try { proxyChild.kill("SIGKILL"); } catch { /* process may already be dead */ } proxyChild = null; } // Kill by PID file using system kill (subprocess). Upgrade runs in CLI so process.kill works; gateway restart runs in daemon where process.kill may be restricted — shell "kill -9" works. try { if (existsSync(CURSOR_PROXY_PID_PATH)) { const pidStr = readFileSync(CURSOR_PROXY_PID_PATH, "utf-8").trim(); const pid = parseInt(pidStr, 10); if (Number.isFinite(pid) && pid > 0) killPidBySubprocess(pid); rmSync(CURSOR_PROXY_PID_PATH, { force: true }); } } catch { /* best-effort kill by PID and remove PID file */ } killPortProcess(opts.port, "SIGKILL"); const waitMs = 200; const maxTries = 30; let portFree = false; for (let i = 0; i < maxTries; i++) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, waitMs); if (!portHasProcess(opts.port)) { portFree = true; break; } } if (!portFree) { opts.logger.error(`Port ${opts.port} still in use after ${(maxTries * waitMs) / 1000}s — retrying in 10s`); startProxyInProgress = false; setTimeout(() => startProxy(opts), 10_000); return; } // Extra kill + short wait before spawn so port is reliably free (e.g. after gateway restart race). killPortProcess(opts.port, "SIGKILL"); Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 500); if (portHasProcess(opts.port)) { opts.logger.warn(`Port ${opts.port} still in use before spawn — retrying in 10s`); startProxyInProgress = false; setTimeout(() => startProxy(opts), 10_000); return; } try { mkdirSync(OPENCLAW_LOGS_DIR, { recursive: true }); } catch {} let stderrBuf = ""; const appendProxyStderr = (chunk: string) => { stderrBuf = (stderrBuf + chunk).slice(-50_000); try { appendFileSync(CURSOR_PROXY_STDERR_LOG_PATH, chunk); } catch {} }; try { const child = spawn("node", [proxyScript], { env: buildProxyChildEnv({ CURSOR_PATH: opts.cursorPath, CURSOR_WORKSPACE_DIR: opts.workspaceDir, CURSOR_PROXY_PORT: String(opts.port), CURSOR_OUTPUT_FORMAT: opts.outputFormat, CURSOR_PROXY_SCRIPT_HASH: computeFileHash(proxyScript), }), stdio: ["ignore", "ignore", "pipe"], }); proxyChild = child; child.stderr?.on("data", (d: Buffer | string) => { const s = Buffer.isBuffer(d) ? d.toString("utf-8") : String(d); appendProxyStderr(s); }); child.on("error", (err: Error) => { const msg = `[proxy-child-error] ${err?.message || String(err)}\n`; appendProxyStderr(msg); opts.logger.error(`Streaming proxy child process error: ${err?.message || String(err)}`); }); lastProxyStartTime = Date.now(); child.on("exit", (code, signal) => { opts.logger.info(`Streaming proxy exited (code ${code}, signal ${signal ?? "none"})`); if (proxyChild === child) proxyChild = null; // Normal exit or killed by signal (e.g. gateway restart / killPortProcess): do not auto-restart. if (code === 0 || code === null || proxyRestartScheduled) return; if (signal != null || (typeof code === "number" && code >= 128 && code <= 255)) return; const stderrSnippet = stderrBuf.trim().slice(-2000); if (stderrSnippet) { opts.logger.warn(`Streaming proxy stderr (tail): ${stderrSnippet.replace(/\s+/g, " ")}`); } const uptime = Date.now() - lastProxyStartTime; if (uptime > PROXY_STABLE_PERIOD) proxyRestartCount = 0; if (proxyRestartCount >= MAX_PROXY_RESTARTS) { opts.logger.error(`Proxy crashed ${proxyRestartCount} times within cooldown, not restarting. Run: openclaw cursor-brain proxy restart`); return; } const delay = PROXY_RESTART_DELAYS[Math.min(proxyRestartCount, PROXY_RESTART_DELAYS.length - 1)]; proxyRestartCount++; opts.logger.warn(`Proxy crashed (code ${code}), restarting in ${delay / 1000}s (attempt ${proxyRestartCount}/${MAX_PROXY_RESTARTS})`); setTimeout(() => startProxy(opts), delay); }); opts.logger.info(`Streaming proxy started on port ${opts.port} (pid ${child.pid})`); startHealthCheck(opts); } finally { startProxyInProgress = false; } } let proxyRestartScheduled = false; function startHealthCheck(opts: { pluginDir: string; cursorPath: string; workspaceDir: string; port: number; outputFormat: OutputFormat; logger: any }) { if (healthCheckTimer) clearInterval(healthCheckTimer); proxyRestartScheduled = false; healthCheckTimer = setInterval(() => { if (!proxyChild || proxyRestartScheduled) return; try { const health = fetchProxyHealth(opts.port); if (!health) return; if (health.status === "degraded") { opts.logger.warn(`Proxy health degraded (failures=${health.consecutiveFailures}, timeouts=${health.consecutiveTimeouts}), restarting...`); proxyRestartScheduled = true; if (healthCheckTimer) clearInterval(healthCheckTimer); try { proxyChild?.kill("SIGKILL"); } catch {} proxyChild = null; setTimeout(() => { proxyRestartScheduled = false; startProxy(opts); }, 2000); } } catch { // health check itself failed — proxy may be down, exit handler will restart it } }, HEALTH_CHECK_INTERVAL); healthCheckTimer.unref(); } const ZERO_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }; const MODEL_DEFAULTS = { input: ["text"], contextWindow: 128000, maxTokens: 8192, cost: ZERO_COST }; function buildProviderConfig(port: number, cursorModels: CursorModel[]) { const models = cursorModels.length ? cursorModels.map((m) => ({ id: m.id, name: m.name, reasoning: m.reasoning, ...MODEL_DEFAULTS })) : [{ id: "auto", name: "Cursor Auto", reasoning: true, ...MODEL_DEFAULTS }]; return { baseUrl: `http://127.0.0.1:${port}/v1`, apiKey: "local", api: "openai-completions", models, }; } function autoSelectModels( models: CursorModel[], currentPrimary?: string, currentFallbacks?: string[], ): { primary: string; fallbacks: string[] } { const defaultModel = models.find((m) => m.isDefault); const primary = currentPrimary || defaultModel?.id || models[0]?.id || "auto"; const fallbacks = currentFallbacks?.length ? currentFallbacks.filter((id) => id !== primary) : models.filter((m) => m.id !== primary).map((m) => m.id); return { primary, fallbacks }; } /** Read default values from openclaw.plugin.json configSchema.properties[].default. */ function getPluginConfigDefaults(): Record { const schema = loadPluginConfigSchema() as { properties?: Record }; const props = schema?.properties; if (!props || typeof props !== "object") return {}; const out: Record = {}; for (const [key, def] of Object.entries(props)) { if (def != null && typeof def === "object" && "default" in def) { out[key] = (def as { default?: unknown }).default; } } return out; } /** Read configSchema.properties from openclaw.plugin.json for schema-driven prompts. */ function getSchemaProperties(): Record { const schema = loadPluginConfigSchema() as { properties?: Record }; return schema?.properties ?? {}; } function parseNum(input: string | undefined, fallback: number): number { if (input == null || input.trim() === "") return fallback; const n = Number(input.trim()); return Number.isFinite(n) ? n : fallback; } /** Merge patch into plugins.entries[PLUGIN_ID].config. User-submitted patch overwrites existing entry.config (patch wins). PLUGIN_CONFIG_EXCLUDE_KEYS are dropped. */ function mergePluginConfig(patch: Record): void { const filtered = { ...patch }; for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete filtered[k]; let cfg: Record = {}; try { cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); } catch {} const plugins = cfg.plugins || {}; const entries = plugins.entries || {}; const entry = entries[PLUGIN_ID] || {}; entry.config = { ...entry.config, ...filtered }; for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete entry.config[k]; entries[PLUGIN_ID] = { ...entry, enabled: entry.enabled ?? true }; plugins.entries = entries; cfg.plugins = plugins; writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n"); } /** * Interactive prompts for plugin config. Initial values: old/saved (current) overrides schema defaults per key; if no old value, use schema default. Returned value is what the user submitted and must overwrite old when saving (see mergePluginConfig). * Model/fallbacks are not in plugin config; they live in agents.defaults.model and providers (see saveModelSelection). */ async function promptPluginConfig(current: Record): Promise | null> { const defaults = getPluginConfigDefaults(); const props = getSchemaProperties(); const cur = { ...defaults, ...current } as Record; const out: Record = { ...current }; try { const clack = loadClack(); clack.note("Press Ctrl+C to skip and use defaults for all options.", "Plugin options"); for (const [key, def] of Object.entries(props)) { const type = def?.type ?? "string"; const message = (def?.description as string) || key; if (type === "boolean") { const val = await clack.confirm({ message, initialValue: cur[key] === true, }); if (clack.isCancel(val)) return null; out[key] = val === true; continue; } if (type === "number") { const defNum = (def?.default as number) ?? 0; const val = await clack.text({ message, initialValue: String(cur[key] ?? def?.default ?? ""), placeholder: String(def?.default ?? ""), }); if (clack.isCancel(val)) return null; out[key] = parseNum(val as string, defNum); continue; } if (type === "string" && Array.isArray(def?.enum) && def.enum.length) { const options = (def.enum as string[]).map((v) => ({ value: v, label: v })); const initial = (cur[key] as string) ?? (def?.default as string) ?? options[0]?.value; const val = await clack.select({ message, options, initialValue: initial, maxItems: Math.min(12, options.length), }); if (clack.isCancel(val)) return null; out[key] = val; continue; } const val = await clack.text({ message, initialValue: String(cur[key] ?? def?.default ?? ""), placeholder: String(def?.default ?? ""), }); if (clack.isCancel(val)) return null; out[key] = (val as string).trim(); } for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete out[k]; return out; } catch (err: any) { console.log(` ⚠ Config prompt failed (${err?.code ?? err?.message ?? String(err)}), keeping current.`); return null; } } async function promptModelSelection( models: CursorModel[], currentPrimary?: string, currentFallbacks?: string[], ): Promise<{ primary: string; fallbacks: string[] } | null> { if (!models.length) { console.log(" ⚠ No models discovered from cursor-agent"); return null; } try { const clack = loadClack(); const toOption = (m: CursorModel) => { const tags: string[] = []; if (m.reasoning) tags.push("thinking"); if (m.isDefault) tags.push("cursor default"); return { value: m.id, label: m.id, hint: `${m.name}${tags.length ? ` (${tags.join(", ")})` : ""}` }; }; const options = models.map(toOption); const primary = await clack.select({ message: "Select primary model (↑↓ navigate, enter confirm)", options, initialValue: currentPrimary || models[0].id, maxItems: 12, }); if (clack.isCancel(primary)) { clack.cancel("Cancelled"); return null; } const fallbackOptions = models.filter((m) => m.id !== primary).map(toOption); const defaultFallbacks = currentFallbacks?.length ? currentFallbacks.filter((id) => id !== primary) : fallbackOptions.map((o) => o.value); const fallbacks = await clack.multiselect({ message: "Select fallback models (space toggle, enter confirm, order follows list)", options: fallbackOptions, initialValues: defaultFallbacks, maxItems: 12, required: false, }); if (clack.isCancel(fallbacks)) { clack.cancel("Cancelled"); return null; } const selectedFallbacks = fallbacks as string[]; clack.log.success(`Primary: ${PROVIDER_ID}/${primary}`); clack.log.success(`Fallbacks: ${selectedFallbacks.length ? selectedFallbacks.map((f) => `${PROVIDER_ID}/${f}`).join(" → ") : "none"}`); return { primary: primary as string, fallbacks: selectedFallbacks }; } catch (err: any) { console.log(` ⚠ Interactive prompt failed (${err?.code ?? err?.message ?? String(err)}), using defaults.`); const result = autoSelectModels(models, currentPrimary, currentFallbacks); console.log(` Primary: ${PROVIDER_ID}/${result.primary}`); console.log(` Fallbacks: ${result.fallbacks.length ? result.fallbacks.map((f) => `${PROVIDER_ID}/${f}`).join(" → ") : "none"}`); console.log(" Run `openclaw cursor-brain setup` in an interactive terminal to choose manually."); return result; } } function saveModelSelection(primary: string, fallbacks: string[], proxyPort: number, models: CursorModel[]) { let config: Record = {}; try { config = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); } catch {} const agents = config.agents || {}; const defaults = agents.defaults || {}; defaults.model = { primary: `${PROVIDER_ID}/${primary}`, fallbacks: fallbacks.map((f) => `${PROVIDER_ID}/${f}`), }; agents.defaults = defaults; config.agents = agents; const modelsSection = config.models || {}; modelsSection.mode = "merge"; const providers = modelsSection.providers || {}; providers[PROVIDER_ID] = buildProviderConfig(proxyPort, models); modelsSection.providers = providers; config.models = modelsSection; writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); } const VALID_SOURCES = ["npm", "archive", "path"] as const; /** * Remove this plugin from openclaw.json (entries, installs, allow; provider + model refs). * Used after "openclaw plugins uninstall" during upgrade — core deletes the extension dir * so we cannot run uninstall.mjs; this inline cleanup makes config valid for "plugins install". */ function removePluginFromOpenClawConfig(): boolean { try { if (!existsSync(OPENCLAW_CONFIG_PATH)) return false; const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")) as Record; const plugins = cfg.plugins || {}; let changed = false; if (plugins.entries?.[PLUGIN_ID]) { delete plugins.entries[PLUGIN_ID]; changed = true; } if (plugins.installs?.[PLUGIN_ID]) { delete plugins.installs[PLUGIN_ID]; changed = true; } if (Array.isArray(plugins.allow)) { const idx = plugins.allow.indexOf(PLUGIN_ID); if (idx !== -1) { plugins.allow.splice(idx, 1); changed = true; } } const prefix = `${PROVIDER_ID}/`; const defaults = cfg.agents?.defaults; if (defaults?.model) { if ((defaults.model.primary || "").toString().startsWith(prefix)) { delete defaults.model.primary; changed = true; } if (defaults.model.fallbacks && Array.isArray(defaults.model.fallbacks)) { const cleaned = defaults.model.fallbacks.filter((f: string) => !f.startsWith(prefix)); if (cleaned.length !== defaults.model.fallbacks.length) { defaults.model.fallbacks = cleaned.length ? cleaned : undefined; changed = true; } } if (!defaults.model.primary && !defaults.model.fallbacks) delete defaults.model; } if (cfg.models?.providers?.[PROVIDER_ID]) { delete cfg.models.providers[PROVIDER_ID]; if (Object.keys(cfg.models.providers || {}).length === 0) delete cfg.models.providers; changed = true; } if (changed) writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n"); return changed; } catch { return false; } } /** * Re-read openclaw.json and fix plugins.installs[PLUGIN_ID].source to a valid * value (or re-add the record if missing after core overwrite). Called from * setImmediate after install so we run after core may have written invalid source. */ function fixInstallRecordSourceOnDisk(installPath: string): void { try { if (!existsSync(OPENCLAW_CONFIG_PATH)) return; const raw = readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"); const cfg = JSON.parse(raw); const plugins = cfg.plugins || {}; const installs = plugins.installs || {}; const entries = plugins.entries || {}; let rec = installs[PLUGIN_ID]; let changed = false; if (!rec) { rec = { installPath, source: "path", sourcePath: resolve(installPath) }; if (existsSync(join(installPath, "package.json"))) { try { rec.version = JSON.parse(readFileSync(join(installPath, "package.json"), "utf-8")).version; } catch { /* ignore */ } } installs[PLUGIN_ID] = rec; plugins.installs = installs; const defaultConfig = getPluginConfigDefaults(); const mergedConfig: Record = { ...defaultConfig, ...(entries[PLUGIN_ID]?.config || {}) }; for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete mergedConfig[k]; entries[PLUGIN_ID] = { ...entries[PLUGIN_ID], enabled: true, config: mergedConfig }; plugins.entries = entries; const allow: string[] = Array.isArray(plugins.allow) ? plugins.allow : []; if (!allow.includes(PLUGIN_ID)) { allow.push(PLUGIN_ID); plugins.allow = allow; } cfg.plugins = plugins; changed = true; } else if (rec.source === "tarball" || (rec.source && !VALID_SOURCES.includes(rec.source as any))) { rec.source = existsSync(join(installPath, "package.json")) ? "path" : "archive"; if (rec.source === "path") rec.sourcePath = resolve(installPath); changed = true; } if (changed) writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n"); } catch { /* ignore */ } } /** * Ensure plugins.installs and plugins.entries in openclaw.json reflect the * actual on-disk state. Both `openclaw plugins install` and `uninstall` may * exit non-zero (e.g. plugins.allow warnings) without persisting config * changes, so the plugin itself must reconcile the record. */ function syncPluginInstallRecord(opts: { installPath: string; source?: string; updateTimestamp?: boolean; /** When set (e.g. after interactive setup), merged on top so user choices are not overwritten by core defaults. */ preservedConfig?: Record; }): void { let config: Record = {}; try { config = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); } catch {} const plugins = config.plugins || {}; const entries = plugins.entries || {}; const installs = plugins.installs || {}; const entry = entries[PLUGIN_ID] || {}; const defaultConfig = getPluginConfigDefaults(); const mergedConfig: Record = { ...defaultConfig, ...entry.config, ...(opts.preservedConfig || {}) }; for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete mergedConfig[k]; entries[PLUGIN_ID] = { ...entry, enabled: true, config: mergedConfig }; const allow: string[] = Array.isArray(plugins.allow) ? plugins.allow : []; if (!allow.includes(PLUGIN_ID)) { allow.push(PLUGIN_ID); plugins.allow = allow; } const version = readPackageVersion(opts.installPath); const prev = installs[PLUGIN_ID] || {}; const record: Record = { ...prev, installPath: opts.installPath }; // OpenClaw only allows source: "npm" | "archive" | "path" if (record.source === "tarball") record.source = "archive"; if (version !== "unknown") record.version = version; if (opts.updateTimestamp !== false) record.installedAt = new Date().toISOString(); if (opts.source) { const abs = resolve(opts.source); if (opts.source.endsWith(".tgz") || opts.source.endsWith(".tar.gz")) { record.source = "archive"; record.sourcePath = abs; delete record.spec; } else if (existsSync(join(abs, "package.json"))) { record.source = "path"; record.sourcePath = abs; delete record.spec; } else { record.source = "npm"; record.spec = opts.source; delete record.sourcePath; } } else if (!record.source || record.source === "tarball") { // Always set a valid source so core validation never sees invalid/tarball if (existsSync(join(opts.installPath, "package.json"))) { record.source = "path"; record.sourcePath = resolve(opts.installPath); delete record.spec; } else { record.source = "npm"; record.spec = PLUGIN_ID; delete record.sourcePath; } } installs[PLUGIN_ID] = record; plugins.entries = entries; plugins.installs = installs; config.plugins = plugins; writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n"); } function resolvePluginDir(api: OpenClawPluginApi): string { const installRecord = (api.config.plugins as any)?.installs?.[PLUGIN_ID]; if (installRecord?.installPath && existsSync(join(installRecord.installPath, "mcp-server", "server.mjs"))) { return installRecord.installPath; } const conventionPath = join(homedir(), ".openclaw", "extensions", PLUGIN_ID); if (existsSync(join(conventionPath, "mcp-server", "server.mjs"))) { return conventionPath; } return api.resolvePath("."); } /** Load configSchema from openclaw.plugin.json so the host shows all options (e.g. requestTimeout, proxy) in config UI; fallback to empty if manifest missing. */ function loadPluginConfigSchema(): Record { try { const pluginDir = dirname(createRequire(import.meta.url).resolve("./package.json")); const manifestPath = join(pluginDir, "openclaw.plugin.json"); if (!existsSync(manifestPath)) return emptyPluginConfigSchema(); const raw = readFileSync(manifestPath, "utf-8"); const manifest = JSON.parse(raw) as { configSchema?: Record }; if (manifest?.configSchema && typeof manifest.configSchema === "object") { return manifest.configSchema as Record; } } catch { /* ignore */ } return emptyPluginConfigSchema(); } const plugin = { id: PLUGIN_ID, name: "Cursor Brain", description: "Use Cursor Agent as the AI brain for OpenClaw via MCP. " + "Auto-discovers plugin tools and proxies them through the Gateway REST API.", configSchema: loadPluginConfigSchema(), register(api: OpenClawPluginApi) { // Fix invalid source value on disk immediately so OpenClaw config validation won't overwrite try { if (existsSync(OPENCLAW_CONFIG_PATH)) { const raw = readFileSync(OPENCLAW_CONFIG_PATH, "utf-8"); const cfg = JSON.parse(raw); const rec = cfg?.plugins?.installs?.[PLUGIN_ID]; if (rec && rec.source && !VALID_SOURCES.includes(rec.source as any)) { rec.source = rec.source === "tarball" ? "archive" : "path"; if (rec.source === "path" && rec.installPath) rec.sourcePath = resolve(rec.installPath); writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n"); } } } catch { /* ignore */ } const pluginDir = resolvePluginDir(api); const config = api.config; const pluginConfig = api.pluginConfig || {}; // Only skip setup when user runs our CLI uninstall/upgrade; "openclaw plugins upgrade" should still run register setup const isCursorBrainUninstallOrUpgrade = process.argv.includes("cursor-brain") && process.argv.some((a) => a === "uninstall" || a === "upgrade"); const isUninstalling = isCursorBrainUninstallOrUpgrade; const argv = process.argv.join(" "); const isProxyCmd = /\bcursor-brain\s+proxy\b/.test(argv); // During "openclaw plugins install", do not start proxy or timers so the install process can exit const isPluginsInstall = process.argv.includes("plugins") && process.argv.includes("install"); // When running "cursor-brain setup" (standalone or as install child), skip starting proxy; user will "gateway restart" to get proxy const isSetupOnly = process.argv.includes("cursor-brain") && process.argv.includes("setup"); if (!isUninstalling) { const ctx: SetupContext = { pluginDir, gatewayPort: config.gateway?.port ?? 18789, gatewayToken: (config.gateway as any)?.auth?.token ?? "", workspaceDir: (config.agents as any)?.defaults?.workspace ?? "", pluginConfig, logger: api.logger, }; const result = runSetup(ctx); for (const w of result.warnings) api.logger.warn(w); for (const e of result.errors) api.logger.error(e); if (result.cursorPath && result.mcpConfigured) { api.logger.info("Cursor Brain setup complete"); } const runInteractiveSetup = isPluginsInstall && result.cursorPath && result.cursorModels.length > 0 && !!process.stdin.isTTY; if (isPluginsInstall && result.cursorPath && !runInteractiveSetup) { api.logger.info("Run 'openclaw cursor-brain setup' to choose primary/fallback models (optional), then restart your gateway to start."); } const proxyPort = parseProxyPort(pluginConfig.proxyPort); const existingProviders = (config as any).models?.providers ?? {}; const discovered = result.cursorModels; const providerExists = !!existingProviders[PROVIDER_ID]; const doSyncInstallRecord = () => { try { syncPluginInstallRecord({ installPath: pluginDir, updateTimestamp: false }); } catch (e: any) { api.logger.warn(`Could not sync install record: ${e?.message ?? String(e)}`); } }; if (result.cursorPath) { try { const newProviderConfig = buildProviderConfig(proxyPort, discovered); const existingProvider = existingProviders[PROVIDER_ID]; const providerUnchanged = existingProvider && JSON.stringify(existingProvider) === JSON.stringify(newProviderConfig); if (providerUnchanged && providerExists) { api.logger.info(`Provider "${PROVIDER_ID}" unchanged (${discovered.length} models, port ${proxyPort})`); doSyncInstallRecord(); // Still ensure default model is set when missing (e.g. config was overwritten or never set) try { let cfg: Record = {}; try { cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); } catch { /* ignore */ } const currentPrimary = (cfg.agents?.defaults?.model as any)?.primary; if (!currentPrimary || String(currentPrimary).startsWith(`${PROVIDER_ID}/`)) { const primary = (currentPrimary as string)?.replace(`${PROVIDER_ID}/`, "") || "auto"; const existingFallbacks = (cfg.agents?.defaults?.model as any)?.fallbacks as string[] | undefined; const fallbacks = existingFallbacks?.length ? existingFallbacks : discovered.filter((m) => m.id !== primary).map((m) => `${PROVIDER_ID}/${m.id}`); const agents = cfg.agents || {}; const defaults = agents.defaults || {}; defaults.model = { primary: `${PROVIDER_ID}/${primary}`, fallbacks }; agents.defaults = defaults; cfg.agents = agents; writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n"); api.logger.info(`Default model set to ${PROVIDER_ID}/${primary}`); } } catch (e: any) { api.logger.warn(`Could not set default model: ${e?.message ?? String(e)}`); } if (runInteractiveSetup) return runInteractiveSetupInProcess({ pluginDir, config, pluginConfig: pluginConfig as Record, result, proxyPort }); } else { // Read fresh config from disk rather than using api.config snapshot, // which may contain stale plugins data (e.g. during install subprocess // where the core has already updated plugins.installs on disk but // api.config still holds the pre-update snapshot). let freshConfig: Record = {}; try { freshConfig = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); } catch { freshConfig = { ...config }; } const patch: Record = { ...freshConfig, models: { ...(freshConfig.models || {}), mode: "merge", providers: { ...(freshConfig.models?.providers || {}), [PROVIDER_ID]: newProviderConfig, }, }, }; const currentPrimary = (freshConfig.agents?.defaults?.model as any)?.primary; const shouldSetDefaultModel = !providerExists || !currentPrimary || String(currentPrimary).startsWith(`${PROVIDER_ID}/`); if (shouldSetDefaultModel) { const primary = (currentPrimary as string)?.replace(`${PROVIDER_ID}/`, "") || "auto"; const existingFallbacks = (freshConfig.agents?.defaults?.model as any)?.fallbacks as string[] | undefined; const fallbacks = existingFallbacks?.length ? existingFallbacks : discovered.filter((m) => m.id !== primary).map((m) => `${PROVIDER_ID}/${m.id}`); (patch as any).agents = { ...(freshConfig.agents || {}), defaults: { ...(freshConfig.agents?.defaults || {}), model: { primary: `${PROVIDER_ID}/${primary}`, fallbacks, }, }, }; } // Ensure OpenClaw-accepted source value (avoids config overwrite during install) const patchInstallRecord = (patch as any).plugins?.installs?.[PLUGIN_ID]; if (patchInstallRecord?.source === "tarball") patchInstallRecord.source = "archive"; if (isPluginsInstall) { try { mkdirSync(dirname(OPENCLAW_CONFIG_PATH), { recursive: true }); writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(patch, null, 2) + "\n"); api.logger.info(`Provider "${PROVIDER_ID}" synced (${discovered.length} models, port ${proxyPort})`); doSyncInstallRecord(); if (runInteractiveSetup) return runInteractiveSetupInProcess({ pluginDir, config, pluginConfig: pluginConfig as Record, result, proxyPort }); setImmediate(() => fixInstallRecordSourceOnDisk(pluginDir)); } catch (err: any) { api.logger.warn(`Could not write config: ${err?.message ?? String(err)}`); doSyncInstallRecord(); if (runInteractiveSetup) return runInteractiveSetupInProcess({ pluginDir, config, pluginConfig: pluginConfig as Record, result, proxyPort }); setImmediate(() => fixInstallRecordSourceOnDisk(pluginDir)); } } else { api.runtime.config.writeConfigFile(patch as any).then(() => { api.logger.info(`Provider "${PROVIDER_ID}" synced (${discovered.length} models, port ${proxyPort})`); doSyncInstallRecord(); }).catch((err: any) => { api.logger.warn(`Could not write config: ${err?.message ?? String(err)}`); doSyncInstallRecord(); }); } } } catch (e: any) { api.logger.warn(`Could not auto-configure: ${e?.message ?? String(e)}`); doSyncInstallRecord(); if (isPluginsInstall) setImmediate(() => fixInstallRecordSourceOnDisk(pluginDir)); } } else { doSyncInstallRecord(); if (isPluginsInstall) setImmediate(() => fixInstallRecordSourceOnDisk(pluginDir)); } // On gateway restart we want proxy to start: use result.cursorPath or fallback to config so proxy always comes up when not in install/setup/uninstall. const effectiveCursorPath = result.cursorPath || detectCursorPath(pluginConfig.cursorPath as string | undefined); const effectiveOutputFormat = result.outputFormat ?? (effectiveCursorPath ? detectOutputFormat(effectiveCursorPath, pluginConfig.outputFormat as string | undefined) : undefined); if (effectiveCursorPath && !isProxyCmd && !isPluginsInstall && !isSetupOnly) { const proxyOpts = { pluginDir, cursorPath: effectiveCursorPath, workspaceDir: ctx.workspaceDir, port: proxyPort, outputFormat: effectiveOutputFormat ?? ("stream-json" as OutputFormat), logger: api.logger, }; // Don't trust in-memory proxyChild when register() runs: on any platform (macOS LaunchAgent, // Linux systemd, Windows service), gateway "restart" may reload in-place (same process). // Re-adopt whatever is on the port so proxy is owned by this process on all platforms. proxyChild = null; const proxyRunning = isProxyRunning(proxyPort); let needRestart = !proxyRunning; if (proxyRunning) { const health = fetchProxyHealth(proxyPort, 3000); if (health) { const proxyScript = join(pluginDir, "mcp-server", "streaming-proxy.mjs"); const installedHash = computeFileHash(proxyScript); if (health.scriptHash !== installedHash) { api.logger.info(`Proxy script changed (running=${health.scriptHash}, installed=${installedHash}), restarting...`); needRestart = true; } } else { needRestart = true; } } if (needRestart) { startProxy(proxyOpts); } else { // Proxy is on port; we cleared proxyChild so we always adopt (kill + start) and own the process. api.logger.info(`Adopting proxy on port ${proxyPort} — killing and restarting under this gateway`); startProxy(proxyOpts); } } } api.registerCli((ctx) => { const prog = ctx.program .command("cursor-brain") .description("Cursor Brain — AI backend management via MCP"); prog .command("setup") .description("Run or re-run MCP server configuration") .action(async () => { const clack = loadClack(); clack.intro(`Cursor Brain Setup (v${readPackageVersion(pluginDir)})`); const s = clack.spinner(); s.start("Configuring MCP server..."); const setupCtx: SetupContext = { pluginDir, gatewayPort: config.gateway?.port ?? 18789, gatewayToken: (config.gateway as any)?.auth?.token ?? "", workspaceDir: (config.agents as any)?.defaults?.workspace ?? "", pluginConfig, logger: api.logger, }; const result = runSetup(setupCtx); if (result.errors.length) { s.stop("Setup failed"); for (const e of result.errors) clack.log.error(e); process.exitCode = 1; return; } s.stop("MCP server configured"); clack.log.info(`Cursor: ${result.cursorPath}`); clack.log.info(`Output format: ${result.outputFormat}`); clack.log.info(`Models found: ${result.cursorModels.length}`); clack.log.info(`MCP config: ${getCursorMcpConfigPath()}`); const currentModel = (config.agents as any)?.defaults?.model; const curPrimary = currentModel?.primary?.replace(`${PROVIDER_ID}/`, ""); const curFallbacks = (currentModel?.fallbacks as string[] | undefined)?.map((f: string) => f.replace(`${PROVIDER_ID}/`, "")); const proxyPort = parseProxyPort(pluginConfig.proxyPort); const selection = await promptModelSelection(result.cursorModels, curPrimary, curFallbacks); if (selection) { try { saveModelSelection(selection.primary, selection.fallbacks, proxyPort, result.cursorModels); clack.log.success("Model configuration saved to openclaw.json (agents.defaults.model + providers)"); } catch (e: any) { clack.log.error(`Could not save model config: ${e?.message ?? String(e)}`); } } // Upgrade: use saved old config as initial so prompt shows old values; otherwise use current pluginConfig. User's submitted values overwrite on save. const initialConfigForPrompt: Record = (() => { const raw = process.env.OPENCLAW_CURSOR_BRAIN_UPGRADE_INITIAL_CONFIG; if (raw) { try { const parsed = JSON.parse(raw) as Record; if (parsed && typeof parsed === "object") return parsed; } catch {} } return pluginConfig as Record; })(); const configResult = await promptPluginConfig(initialConfigForPrompt); if (configResult) { try { mergePluginConfig(configResult); clack.log.success("Plugin configuration saved to openclaw.json"); } catch (e: any) { clack.log.error(`Could not save config: ${e?.message ?? String(e)}`); } } try { syncPluginInstallRecord({ installPath: pluginDir, updateTimestamp: false, preservedConfig: configResult ?? undefined }); } catch {} if (!gatewayRestartOutroShown) { gatewayRestartOutroShown = true; clack.outro("Run `openclaw gateway restart` (or restart your gateway) to apply changes."); } process.exit(0); }); prog .command("doctor") .description("Check Cursor Brain health") .action(() => { const checks = runDoctorChecks({ gatewayPort: config.gateway?.port ?? 18789, gatewayToken: (config.gateway as any)?.auth?.token ?? "", pluginDir, cursorPathOverride: pluginConfig.cursorPath as string | undefined, }); console.log(formatDoctorResults(checks)); if (checks.some((c) => !c.ok)) process.exitCode = 1; }); prog .command("status") .description("Show current configuration status") .action(() => { const cursorPath = detectCursorPath(pluginConfig.cursorPath as string | undefined); const model = (config.agents as any)?.defaults?.model; const pluginVersion = readPackageVersion(pluginDir); let cursorVersion = "unknown"; if (cursorPath) { try { const out = execSync(`"${cursorPath}" --version`, { encoding: "utf-8", timeout: 10000, stdio: ["pipe", "pipe", "pipe"], }); cursorVersion = out.trim().split("\n")[0]?.trim() || "unknown"; } catch { /* ignore */ } } let detectedFormat: OutputFormat | "n/a" = "n/a"; if (cursorPath) { detectedFormat = detectOutputFormat(cursorPath, pluginConfig.outputFormat as string | undefined); } const toolCandidates = countDiscoveredTools() ?? 0; const proxyPort = parseProxyPort(pluginConfig.proxyPort); const proxyUp = isProxyRunning(proxyPort); const providers = (config as any).models?.providers ?? {}; const hasProvider = !!providers[PROVIDER_ID]; console.log("Cursor Brain Status\n"); console.log(` Plugin version: v${pluginVersion}`); console.log(` Platform: ${process.platform}`); console.log(` Plugin dir: ${pluginDir}`); console.log(` Cursor path: ${cursorPath || "not found"}`); console.log(` Cursor version: ${cursorVersion}`); console.log(` Output format: ${detectedFormat}`); console.log(` Streaming proxy: ${proxyUp ? `running on :${proxyPort}` : "not running"}`); console.log(` Provider: ${hasProvider ? `"${PROVIDER_ID}" configured` : "not configured"}`); console.log(` Primary model: ${model?.primary || "not set"}`); const fb = model?.fallbacks; console.log(` Fallbacks (${fb?.length || 0}): ${fb?.length ? fb.slice(0, 5).join(" → ") + (fb.length > 5 ? ` … +${fb.length - 5} more` : "") : "none"}`); console.log(` Gateway: http://127.0.0.1:${config.gateway?.port ?? 18789}`); console.log(` MCP config: ${getCursorMcpConfigPath()}`); console.log(` Tool candidates: ${toolCandidates} (from plugin sources)`); }); prog .command("uninstall") .description("Clean up configurations and remove the plugin completely") .action(() => { const uninstallScript = join(pluginDir, "scripts", "uninstall.mjs"); console.log(`Cursor Brain Uninstall (v${readPackageVersion(pluginDir)})\n`); if (existsSync(uninstallScript)) { console.log("[1/2] Running uninstall script (config + MCP + provider + extension dir)..."); try { execSync(`node "${uninstallScript}"`, { encoding: "utf-8", stdio: "inherit", timeout: 30000, }); } catch (e: any) { if (e?.status !== undefined && e.status !== 0) { console.error(" ✗ Uninstall script exited with code", e.status); process.exitCode = 1; } } } console.log("[2/2] Removing plugin registration from OpenClaw..."); try { execSync(`openclaw plugins uninstall ${PLUGIN_ID}`, { encoding: "utf-8", input: "y\n", timeout: 30000, stdio: ["pipe", "pipe", "pipe"], }); console.log(" ✓ Plugin unregistered"); } catch { console.log(" - Already unregistered or command failed"); } console.log("\nRun `openclaw gateway restart` (or restart your gateway) to apply changes."); }); prog .command("upgrade ") .description("Upgrade plugin from a path, .tgz archive, or npm spec") .action(async (source: string) => { const clack = loadClack(); const installPath = join(homedir(), ".openclaw", "extensions", PLUGIN_ID); const oldVersion = readPackageVersion(pluginDir); const sourceVersion = readPackageVersion(source); const versionHint = sourceVersion !== "unknown" ? `v${oldVersion} → v${sourceVersion}` : `v${oldVersion} → ${source}`; clack.intro(`Cursor Brain Upgrade (${versionHint})`); let savedPluginConfig: Record | null = null; try { const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); const entry = cfg?.plugins?.entries?.[PLUGIN_ID]; if (entry && typeof entry === "object" && entry.config != null) { savedPluginConfig = { ...entry.config }; } } catch {} if (sourceVersion !== "unknown" && oldVersion !== "unknown") { const cmp = compareSemver(sourceVersion, oldVersion); if (cmp < 0) { try { const ok = await clack.confirm({ message: `Target version v${sourceVersion} is older than current v${oldVersion}. Downgrade anyway?` }); if (ok !== true) { clack.log.info("Upgrade cancelled"); return; } } catch { clack.log.warn(`Target version v${sourceVersion} is older than current v${oldVersion}. Proceeding (non-interactive).`); } } else if (cmp === 0) { try { const ok = await clack.confirm({ message: `Target version v${sourceVersion} is the same as current. Reinstall?` }); if (ok !== true) { clack.log.info("Upgrade cancelled"); return; } } catch { clack.log.info(`Reinstalling v${sourceVersion} (non-interactive).`); } } } const s = clack.spinner(); // Kill proxy first so port is free before install (upgrade does not run uninstall.mjs, so proxy is never stopped otherwise). let proxyPort = DEFAULT_PROXY_PORT; try { const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); const portFromConfig = cfg?.plugins?.entries?.[PLUGIN_ID]?.config?.proxyPort; if (portFromConfig != null) proxyPort = parseProxyPort(portFromConfig); } catch { /* config read failed, use default port */ } s.start("Stopping streaming proxy..."); try { if (existsSync(CURSOR_PROXY_PID_PATH)) { const pidStr = readFileSync(CURSOR_PROXY_PID_PATH, "utf-8").trim(); const pid = parseInt(pidStr, 10); if (Number.isFinite(pid) && pid > 0) killPidBySubprocess(pid); rmSync(CURSOR_PROXY_PID_PATH, { force: true }); } } catch { /* best-effort kill by PID and remove PID file */ } killPortProcess(proxyPort, "SIGKILL"); for (let w = 0; w < 20; w++) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 150); if (!portHasProcess(proxyPort)) break; } try { if (existsSync(CURSOR_PROXY_PID_PATH)) rmSync(CURSOR_PROXY_PID_PATH, { force: true }); } catch { /* best-effort remove PID file */ } s.stop("Streaming proxy stopped"); s.start("Removing old plugin..."); try { execSync(`openclaw plugins uninstall ${PLUGIN_ID}`, { encoding: "utf-8", input: "y\n", timeout: 30000, stdio: ["pipe", "pipe", "pipe"], }); } catch (e: any) { const msg = e?.stderr || e?.stdout || e?.message || String(e); if (msg) clack.log.warn(`Uninstall CLI output: ${msg.slice(0, 500)}`); } if (existsSync(installPath)) { rmSync(installPath, { recursive: true, force: true }); } // Core uninstall deletes the extension dir, so uninstall.mjs is no longer available. // Inline cleanup so config is valid for "openclaw plugins install" (no plugins.allow orphan). removePluginFromOpenClawConfig(); s.stop(`Old plugin removed (v${oldVersion})`); // Resolve source: only treat as path when source explicitly looks like one (./path, /abs, .tgz). // Bare names like "openclaw-cursor-brain" or "openclaw-cursor-brain@latest" are npm specs — do not // resolve with cwd else a local dir with that name is wrongly used and install fails. const looksLikePath = source.startsWith(".") || isAbsolute(source) || source.includes("/") || source.includes("\\") || source.endsWith(".tgz") || source.endsWith(".tar.gz"); const baseDir = looksLikePath ? (process.env.PWD || process.cwd()) : process.cwd(); let resolvedPath = isAbsolute(source) ? source : resolve(baseDir, source); if (looksLikePath && !isAbsolute(source) && resolvedPath === installPath) { resolvedPath = resolve(process.cwd(), source); if (resolvedPath === installPath) { clack.log.error("Cannot resolve source: current directory appears to be the plugin dir. Use an absolute path: openclaw cursor-brain upgrade /path/to/openclaw-cursor-brain"); process.exitCode = 1; return; } clack.log.warn(`Using PWD (${baseDir}) to resolve "${source}"; process.cwd() was the plugin dir.`); } const isLocalPath = looksLikePath && ( isAbsolute(source) || source.startsWith(".") || existsSync(resolvedPath) || existsSync(join(resolvedPath, "package.json")) ); const installSource = isLocalPath ? resolvedPath : source; s.start(`Installing from ${source}...`); const installCwd = isLocalPath ? resolvedPath : process.cwd(); const installArgs = isLocalPath ? ["plugins", "install", "."] : ["plugins", "install", source]; const installResult = spawnSync("openclaw", installArgs, { cwd: installCwd, encoding: "utf-8", timeout: 60000, stdio: ["pipe", "pipe", "pipe"], }); let installError: string | undefined; if (installResult.status !== 0 || installResult.error) { installError = [installResult.stderr, installResult.stdout, installResult.error?.message].filter(Boolean).join("\n").trim().slice(0, 800); } let pluginEntry = join(installPath, "index.ts"); if (!existsSync(pluginEntry) && isLocalPath) { s.start("Copying from source..."); mkdirSync(installPath, { recursive: true }); try { cpSync(resolvedPath, installPath, { recursive: true, filter: (src) => !/[/\\]node_modules([/\\]|$)|[/\\]\.git([/\\]|$)/.test(src), }); } catch (copyErr: any) { clack.log.error(`Fallback copy failed: ${copyErr?.message || copyErr}`); } pluginEntry = join(installPath, "index.ts"); } if (!existsSync(pluginEntry)) { s.stop("Install failed"); clack.log.error("Plugin files not found after install. Try: openclaw plugins install ./"); clack.log.error(` installPath: ${installPath}`); clack.log.error(` source (resolved): ${resolvedPath}`); clack.log.error(` isLocalPath: ${isLocalPath}`); if (installError) clack.log.error(` install command stderr/stdout:\n${installError}`); process.exitCode = 1; return; } s.stop(`New version installed (v${readPackageVersion(installPath)})`); try { syncPluginInstallRecord({ installPath, source: installSource, updateTimestamp: true }); } catch (e: any) { clack.log.warn(`Could not sync install record: ${e?.message ?? String(e)}`); } if (savedPluginConfig && Object.keys(savedPluginConfig).length > 0) { try { const filtered = { ...savedPluginConfig }; for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete filtered[k]; if (Object.keys(filtered).length > 0) { const cfg = JSON.parse(readFileSync(OPENCLAW_CONFIG_PATH, "utf-8")); const plugins = cfg.plugins || {}; const entries = plugins.entries || {}; const entry = entries[PLUGIN_ID] || {}; entry.config = { ...entry.config, ...filtered }; for (const k of PLUGIN_CONFIG_EXCLUDE_KEYS) delete entry.config[k]; entries[PLUGIN_ID] = { ...entry, enabled: entry.enabled ?? true }; plugins.entries = entries; cfg.plugins = plugins; writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(cfg, null, 2) + "\n"); } } catch {} } // Run post-install steps (discover models, model selection, plugin config) in a new process // so that the NEWLY INSTALLED plugin script is used, not the old one still in this process. // Pass saved config via env so setup prompt shows old values as initial; user's submitted values then overwrite on save. s.stop("Delegating to new version for configuration..."); const setupEnv = { ...process.env }; if (savedPluginConfig && Object.keys(savedPluginConfig).length > 0) { try { setupEnv.OPENCLAW_CURSOR_BRAIN_UPGRADE_INITIAL_CONFIG = JSON.stringify(savedPluginConfig); } catch {} } try { execSync("openclaw cursor-brain setup", { encoding: "utf-8", stdio: "inherit", timeout: 600000, env: setupEnv, }); } catch (e: any) { if (e?.status !== undefined && e.status !== 0) { clack.log.warn(`Setup exited with code ${e.status}`); } } // Setup child already showed "gateway restart" outro; parent only shows short completion to avoid duplicate. clack.outro("Upgrade complete."); process.exit(0); }); // ── proxy subcommand group ────────────────────────────────────────── const proxyCmd = prog .command("proxy") .description("Manage the streaming proxy process"); proxyCmd .action(() => { const proxyPort = parseProxyPort(pluginConfig.proxyPort); let up = false; let pid = ""; let sessions = ""; const health = fetchProxyHealth(proxyPort); if (health) { up = health.status === "ok" || health.status === "degraded"; sessions = String(health.sessions ?? "?"); } if (up) { try { if (process.platform === "win32") { const out = execSync(`netstat -ano | findstr :${proxyPort} | findstr LISTENING`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"], }).trim(); pid = out.split("\n")[0]?.trim().split(/\s+/).pop() || ""; } else { pid = execSync(`lsof -ti :${proxyPort}`, { encoding: "utf-8", timeout: 3000, stdio: ["pipe", "pipe", "pipe"] }).trim().split("\n")[0] || ""; } } catch {} } console.log("Streaming Proxy Status\n"); console.log(` Status: ${up ? "running" : "stopped"}`); console.log(` Port: ${proxyPort}`); if (pid) console.log(` PID: ${pid}`); if (sessions) console.log(` Sessions: ${sessions}`); console.log(` Log file: ${CURSOR_PROXY_LOG_PATH}`); }); proxyCmd .command("stop") .description("Stop the streaming proxy") .action(async () => { const proxyPort = parseProxyPort(pluginConfig.proxyPort); if (!isProxyRunning(proxyPort)) { console.log("Proxy is not running."); return; } try { if (existsSync(CURSOR_PROXY_PID_PATH)) { const pidStr = readFileSync(CURSOR_PROXY_PID_PATH, "utf-8").trim(); const pid = parseInt(pidStr, 10); if (Number.isFinite(pid) && pid > 0) killPidBySubprocess(pid); rmSync(CURSOR_PROXY_PID_PATH, { force: true }); } } catch { /* best-effort kill by PID and remove PID file */ } killPortProcess(proxyPort, "SIGKILL"); await new Promise((r) => setTimeout(r, 500)); try { if (existsSync(CURSOR_PROXY_PID_PATH)) rmSync(CURSOR_PROXY_PID_PATH, { force: true }); } catch { /* best-effort remove PID file */ } if (isProxyRunning(proxyPort)) { console.error(`Proxy on port ${proxyPort} may still be running. Try: kill -9 $(lsof -ti :${proxyPort})`); } else { console.log(`Proxy on port ${proxyPort} stopped.`); } }); proxyCmd .command("restart") .description("Restart the streaming proxy (detached)") .action(async () => { const proxyPort = parseProxyPort(pluginConfig.proxyPort); const cursorPath = detectCursorPath(pluginConfig.cursorPath as string | undefined); if (!cursorPath) { console.error("Cannot restart: cursor-agent not found."); process.exitCode = 1; return; } const proxyScript = join(pluginDir, "mcp-server", "streaming-proxy.mjs"); if (!existsSync(proxyScript)) { console.error(`Cannot restart: proxy script not found at ${proxyScript}`); process.exitCode = 1; return; } if (isProxyRunning(proxyPort)) { killPortProcess(proxyPort, "SIGKILL"); await new Promise((r) => setTimeout(r, 500)); try { if (existsSync(CURSOR_PROXY_PID_PATH)) rmSync(CURSOR_PROXY_PID_PATH, { force: true }); } catch { /* best-effort remove PID file */ } } const outputFormat = detectOutputFormat(cursorPath, pluginConfig.outputFormat as string | undefined); const child = spawn("node", [proxyScript], { env: buildProxyChildEnv({ CURSOR_PATH: cursorPath, CURSOR_WORKSPACE_DIR: (config.agents as any)?.defaults?.workspace ?? "", CURSOR_PROXY_PORT: String(proxyPort), CURSOR_OUTPUT_FORMAT: outputFormat, CURSOR_PROXY_SCRIPT_HASH: computeFileHash(proxyScript), }), stdio: "ignore", detached: true, }); child.unref(); console.log(`Proxy restarted on port ${proxyPort} (pid ${child.pid}).`); }); proxyCmd .command("log") .description("Show recent proxy log entries") .option("-n, --lines ", "Number of lines to show", "30") .action((opts: { lines: string }) => { const logPath = CURSOR_PROXY_LOG_PATH; if (!existsSync(logPath)) { console.log(`No proxy log file found at ${logPath}.`); return; } const n = Math.max(1, parseInt(opts.lines, 10) || 30); try { if (process.platform !== "win32") { const out = execSync(`tail -n ${n} "${logPath}"`, { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] }); console.log(out.trimEnd()); } else { const content = readFileSync(logPath, "utf-8"); const lines = content.trimEnd().split("\n"); console.log(lines.slice(-n).join("\n")); } } catch (e: any) { try { const content = readFileSync(logPath, "utf-8"); const lines = content.trimEnd().split("\n"); console.log(lines.slice(-n).join("\n")); } catch (err: any) { console.error(`Could not read log: ${err?.message || e?.message}`); } } }); }, { commands: ["cursor-brain"] }); }, }; export default plugin;