echopai 2.1.0 → 2.3.0

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/bin.js CHANGED
@@ -14,26 +14,57 @@
14
14
  *
15
15
  * See docs/PLAN_CLI_V2_REWRITE.md for design details.
16
16
  */
17
+ import { spawn } from "node:child_process";
18
+ import { fileURLToPath } from "node:url";
19
+ import path from "node:path";
17
20
  import { Command, Option } from "commander";
18
21
  import { buildCommandTree } from "./_generated/commands.js";
19
22
  import { invoke } from "./runtime/invoker.js";
20
23
  import { buildApiCommand } from "./tools/api.js";
21
24
  import { buildMcpCommand } from "./tools/mcp.js";
22
25
  import { buildRawCallCommand } from "./tools/raw.js";
23
- import { buildBarsBatchCommand, buildChartCommand, buildDigestCommand, buildHotCommand, buildLookupCommand, buildNewsCommand, buildQuoteCommand, buildResearchCommand, buildScanCommand, buildSearchCommand, buildSentimentCommand, buildViewsCommand, } from "./verbs/index.js";
26
+ import { buildAnnouncementsCommand, buildBarsBatchCommand, buildChartCommand, buildConceptsCommand, buildDigestCommand, buildFinancialsCommand, buildHotCommand, buildLimitUpCommand, buildLookupCommand, buildMarketCommand, buildNewsCommand, buildQuoteCommand, buildScanCommand, buildSearchCommand, buildSentimentCommand, buildViewsCommand, } from "./verbs/index.js";
24
27
  import { buildCompletionCommand } from "./tools/completion.js";
25
28
  import { buildConfigCommand } from "./tools/config.js";
26
29
  import { buildLoginCommand, buildLogoutCommand, buildStatusCommand } from "./tools/login.js";
27
30
  import { buildDoctorCommand } from "./tools/doctor.js";
28
31
  import { buildSchemaCommand } from "./tools/schema.js";
29
32
  import { buildTraceCommand } from "./tools/trace.js";
33
+ import { buildUpgradeCommand } from "./tools/upgrade.js";
34
+ import { buildWelcomeCommand, printWelcome, shouldShowWelcome } from "./tools/welcome.js";
30
35
  import { buildWhoamiCommand } from "./tools/whoami.js";
31
36
  import { CLI_VERSION } from "./version.js";
37
+ // ─── Background update check ────────────────────────────────────────────────
38
+ // Spawn a detached child that hits npm registry and writes a 24h cache file.
39
+ // Parent exits immediately (child.unref). Banner is emitted on next CLI run
40
+ // from the cache (zero hot-path network cost). Skipped in CI / non-TTY /
41
+ // opt-out env so AI agents and scripts pay nothing.
42
+ try {
43
+ const optedOut = process.env.CI ||
44
+ process.env.ECHOPAI_DISABLE_UPDATE_CHECK ||
45
+ !process.stderr.isTTY;
46
+ // Don't recurse: the worker entry sets this env so we skip spawning again
47
+ // from inside the worker process itself.
48
+ if (!optedOut && !process.env._ECHOPAI_UPDATE_WORKER) {
49
+ const here = path.dirname(fileURLToPath(import.meta.url));
50
+ const workerPath = path.join(here, "runtime", "update_worker.js");
51
+ const child = spawn(process.execPath, [workerPath, CLI_VERSION], {
52
+ detached: true,
53
+ stdio: "ignore",
54
+ windowsHide: true,
55
+ env: { ...process.env, _ECHOPAI_UPDATE_WORKER: "1" },
56
+ });
57
+ child.unref();
58
+ }
59
+ }
60
+ catch {
61
+ // Worker spawn is best-effort; never let it break the CLI.
62
+ }
32
63
  const program = new Command();
33
64
  program
34
65
  .name("echopai")
35
66
  .description("EchoPai CLI v1 — partner-grade access to stock-market data, news, sentiment,\n" +
36
- "views, signals, backtest.\n\n" +
67
+ "and analyst views.\n\n" +
37
68
  "AI-First: JSON envelope on stdout, JSON errors on stderr, three-state exit\n" +
38
69
  "codes (0 success / 1 user error / 2 service error). Set ECHOPAI_KEY env\n" +
39
70
  "var or run `echopai login`.")
@@ -50,12 +81,19 @@ program
50
81
  .addOption(new Option("--max-bytes <n>", "Truncate serialized envelope to N bytes (sets meta.truncated)"))
51
82
  .addOption(new Option("--yes", "Confirm a write op in non-TTY mode (required for sideEffect=write in agents / CI)"))
52
83
  .addOption(new Option("--dry-run", "Send X-Dry-Run:1 on write ops where the server advertises dry-run support"))
53
- .addHelpText("after", "\nAuthentication: ECHOPAI_KEY=<eps_live_<lookup>_<secret>> echopai <cmd>\n" +
54
- "Raw mirror: echopai raw <noun> <verb> # all OpenAPI ops, e.g. raw news search\n" +
55
- "Curated verbs: echopai <verb> # task-level entry (Phase 3.6+)\n" +
56
- "Capability: echopai whoami | echopai doctor\n" +
57
- "Schema dump: echopai schema export # AI agents pipe this for command surface\n" +
58
- "Profile: echopai config profile add prod --base-url https://api.echopai.com\n");
84
+ .addHelpText("after", "\nAuthentication: ECHOPAI_KEY=<eps_live_<lookup>_<secret>> echopai <cmd>\n" +
85
+ "Raw mirror: echopai raw <noun> <verb> # all OpenAPI ops, e.g. raw news search\n" +
86
+ "Curated verbs: echopai <verb> # task-level entry (Phase 3.6+)\n" +
87
+ "Market session: echopai market status # pre-open/open/lunch/closed + next trading day\n" +
88
+ "Market movers: echopai market movers --sort pct --top 20 # /market 行情榜(pct / pct_asc / speed / amount / turnover / total_mv)\n" +
89
+ "Announcements: echopai announcements stock --code SSE:600519 # cninfo A-share disclosures\n" +
90
+ "Concepts: echopai concepts alerts # 当前激活的概念异动(big_move / limit_up_cluster)\n" +
91
+ "Limit-up: echopai limit-up summary # 涨停数 / 炸板数 / 连板梯队\n" +
92
+ "Fundamentals: echopai financials quote-snapshot --code SSE:600519 # PE/PB/PS/换手率/股息率/量比 14-field snapshot\n" +
93
+ "Capability: echopai whoami | echopai doctor\n" +
94
+ "Schema dump: echopai schema export # AI agents pipe this for command surface\n" +
95
+ "Profile: echopai config profile add prod --base-url https://api.echopai.com\n" +
96
+ "Self-update: echopai upgrade [--check] [--exec] # check npm registry; --exec runs `npm i -g echopai@latest`\n");
59
97
  const dispatch = async (op, args) => {
60
98
  // commander stores global flags on the *root* command's opts in camelCase
61
99
  // (e.g. --max-bytes → maxBytes). Subcommand-level opts go through
@@ -100,7 +138,7 @@ function applyAiFirstHooks(cmd) {
100
138
  //
101
139
  // Phase 3.7+ — `raw` is now the only place to find spec-driven operations.
102
140
  // Top-level previously held spec mirrors but starting this PR the curated
103
- // verbs (`views` / `news` / `quote` / `sentiment` / `research` / `hot` /
141
+ // verbs (`views` / `news` / `quote` / `sentiment` / `hot` /
104
142
  // `lookup` / `digest` etc.) take precedence at the top level. Use `echopai raw <noun>
105
143
  // <verb>` for the raw mirror (e.g. `raw views feed`, `raw quote`, etc.).
106
144
  const rawCmd = new Command("raw").description("Raw 1:1 mirror of OpenAPI operations (escape hatch for power users / scripts)");
@@ -108,7 +146,7 @@ buildCommandTree(rawCmd, dispatch);
108
146
  rawCmd.addCommand(buildRawCallCommand());
109
147
  program.addCommand(rawCmd);
110
148
  // Top-level spec commands kept ONLY for nouns that don't conflict with a
111
- // curated verb. Curated-verb nouns (views/news/quote/sentiment/research/hot
149
+ // curated verb. Curated-verb nouns (views/news/quote/sentiment/hot
112
150
  // /lookup/digest) are NOT generated at top level — they go below in their canonical
113
151
  // curated form. Use `echopai raw <noun>` for the spec mirror.
114
152
  const CURATED_NOUNS = new Set([
@@ -116,11 +154,15 @@ const CURATED_NOUNS = new Set([
116
154
  "news",
117
155
  "quote",
118
156
  "sentiment",
119
- "research",
120
157
  "hot",
121
158
  "lookup",
122
159
  "digest",
123
160
  "search",
161
+ "financials",
162
+ "announcements",
163
+ "market",
164
+ "concepts",
165
+ "limit-up",
124
166
  ]);
125
167
  const topLevelSpecDispatch = async (op, args) => {
126
168
  await dispatch(op, args);
@@ -135,19 +177,23 @@ for (const sub of topLevelStub.commands) {
135
177
  // Curated agent verbs (Phase 3.6+): task-level entries that wrap one or
136
178
  // more raw operations + map output for AI agents. Listed before tools so
137
179
  // `--help` shows them first.
138
- // Product priority (see feedback_views_over_news.md): views > research > news
180
+ // Product priority (see feedback_views_over_news.md): views > news
139
181
  program.addCommand(buildLookupCommand());
140
182
  program.addCommand(buildDigestCommand()); // one-shot fan-out (Phase 5.1)
141
183
  program.addCommand(buildSearchCommand()); // hybrid semantic discovery (PLAN_SEARCH_SEMANTIC_UPGRADE)
142
184
  program.addCommand(buildQuoteCommand());
185
+ program.addCommand(buildMarketCommand()); // session state (pre-open / open / lunch / closed)
143
186
  program.addCommand(buildViewsCommand()); // PRIMARY research source
144
- program.addCommand(buildResearchCommand()); // views quality signal layer
145
187
  program.addCommand(buildNewsCommand()); // supplementary breadth
188
+ program.addCommand(buildAnnouncementsCommand()); // cninfo A-share disclosures (feed/stock/detail)
146
189
  program.addCommand(buildSentimentCommand());
147
190
  program.addCommand(buildHotCommand());
191
+ program.addCommand(buildConceptsCommand()); // concept indices + alerts + bars + news/views
192
+ program.addCommand(buildLimitUpCommand()); // 涨停股池 / 涨停统计 / 涨停趋势
148
193
  program.addCommand(buildChartCommand()); // single-code K-line
149
194
  program.addCommand(buildBarsBatchCommand()); // multi-code batch K-line (PR 3.2/3.3 server)
150
195
  program.addCommand(buildScanCommand()); // full-market scan (PR 3.4 server)
196
+ program.addCommand(buildFinancialsCommand()); // fundamentals: quote-snapshot / pit / reports / series
151
197
  // hand-curated tools
152
198
  program.addCommand(buildLoginCommand());
153
199
  program.addCommand(buildLogoutCommand());
@@ -160,7 +206,15 @@ program.addCommand(buildWhoamiCommand());
160
206
  program.addCommand(buildDoctorCommand());
161
207
  program.addCommand(buildMcpCommand());
162
208
  program.addCommand(buildCompletionCommand());
209
+ program.addCommand(buildWelcomeCommand());
210
+ program.addCommand(buildUpgradeCommand());
163
211
  applyAiFirstHooks(program);
212
+ // Bare `echopai` in a TTY → onboarding screen. Non-TTY / CI / piped output
213
+ // falls through to commander's plain help (preserves AI-first invariant).
214
+ if (shouldShowWelcome(process.argv)) {
215
+ printWelcome();
216
+ process.exit(0);
217
+ }
164
218
  program.parseAsync(process.argv).catch((e) => {
165
219
  process.stderr.write(JSON.stringify({
166
220
  error: {
@@ -23,6 +23,7 @@ import { generateIdempotencyKey, announceKey } from "./idempotency.js";
23
23
  import { writeStdout, writeStderr } from "./io.js";
24
24
  import { paginate, exitCodeForPaginateError } from "./paginator.js";
25
25
  import { renderError, isTtyHuman } from "./tty.js";
26
+ import { maybeEmitUpdateBanner } from "./update_check.js";
26
27
  const Ajv = (AjvPkg.default ??
27
28
  AjvPkg);
28
29
  const addFormats = (addFormatsPkg.default ??
@@ -180,6 +181,7 @@ export async function invoke(op, args, ctx) {
180
181
  });
181
182
  await writeStderr(`[paginate] ${result.pages} pages, ${result.items} items\n`);
182
183
  trace(op, { exit_code: 0 });
184
+ maybeEmitUpdateBanner();
183
185
  process.exit(0);
184
186
  }
185
187
  catch (e) {
@@ -293,6 +295,7 @@ export async function invoke(op, args, ctx) {
293
295
  // 8. success path — render
294
296
  if (raw) {
295
297
  await writeStdout(bodyText + "\n");
298
+ maybeEmitUpdateBanner();
296
299
  process.exit(0);
297
300
  }
298
301
  const rawEnvelope = buildResponseEnvelope(bodyJson ?? bodyText, {
@@ -337,6 +340,7 @@ export async function invoke(op, args, ctx) {
337
340
  exit_code: 0,
338
341
  truncated: envelope.meta.truncated === true,
339
342
  });
343
+ maybeEmitUpdateBanner();
340
344
  process.exit(0);
341
345
  }
342
346
  function buildQueryString(params) {
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Update-availability check — passive, AI-safe.
3
+ *
4
+ * 设计:
5
+ * - 真正发网络的活儿在 detached child (`update_worker.ts`) 干,由 bin.ts
6
+ * 启动时 spawn + unref,父进程立刻继续。Worker 写 ~/.config/echopai/
7
+ * update_cache.json,下次 CLI 启动时 sync 读 cache → 决定是否打 banner。
8
+ * - 这里只做 sync reader + 比较 + emit banner。任何 IO / 解析失败都吞掉
9
+ * 当作"没有可用更新"——CLI 自更新链路任何环节都不该让正常命令失败。
10
+ *
11
+ * AI-safe / agent-safe 规则:
12
+ * - 默认只在 stderr.isTTY 时 emit banner(agent 走 pipe 不 TTY,无污染)
13
+ * - 环境变量 `ECHOPAI_DISABLE_UPDATE_CHECK=1` / `CI=1` 一律静默
14
+ * - banner 是单行 dim ANSI,不破坏 NDJSON / JSON envelope(stdout 不动)
15
+ */
16
+ import { readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { CLI_VERSION } from "../version.js";
20
+ export const UPDATE_CACHE_PATH = path.join(os.homedir(), ".config", "echopai", "update_cache.json");
21
+ /** Pure semver-lite comparator. Pre-release suffixes are best-effort sorted
22
+ * lexicographically AFTER the numeric tuple, so `2.3.0-beta.1 < 2.3.0`. */
23
+ export function compareSemver(a, b) {
24
+ const parse = (s) => {
25
+ const [core, pre = ""] = s.replace(/^v/, "").split("-", 2);
26
+ const nums = core.split(".").map((n) => Number.parseInt(n, 10) || 0);
27
+ return { nums, pre };
28
+ };
29
+ const pa = parse(a);
30
+ const pb = parse(b);
31
+ const len = Math.max(pa.nums.length, pb.nums.length);
32
+ for (let i = 0; i < len; i++) {
33
+ const d = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0);
34
+ if (d !== 0)
35
+ return d > 0 ? 1 : -1;
36
+ }
37
+ // pre-release: empty > non-empty (release > prerelease per semver)
38
+ if (pa.pre === pb.pre)
39
+ return 0;
40
+ if (pa.pre === "")
41
+ return 1;
42
+ if (pb.pre === "")
43
+ return -1;
44
+ return pa.pre < pb.pre ? -1 : 1;
45
+ }
46
+ export function isNewer(latest, current) {
47
+ try {
48
+ return compareSemver(latest, current) > 0;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ /** Sync read; returns null on any error (missing file / parse fail / etc.). */
55
+ export function readCachedUpdate() {
56
+ try {
57
+ const raw = readFileSync(UPDATE_CACHE_PATH, "utf-8");
58
+ const obj = JSON.parse(raw);
59
+ if (typeof obj.checked_at === "string" &&
60
+ typeof obj.current === "string" &&
61
+ typeof obj.latest === "string") {
62
+ return obj;
63
+ }
64
+ return null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ /** Best-effort cache write (atomic via tmp + rename). Returns true on success. */
71
+ export function writeCachedUpdate(info) {
72
+ try {
73
+ mkdirSync(path.dirname(UPDATE_CACHE_PATH), { recursive: true });
74
+ const tmp = UPDATE_CACHE_PATH + "." + process.pid + ".tmp";
75
+ writeFileSync(tmp, JSON.stringify(info) + "\n", { encoding: "utf-8" });
76
+ // Atomic rename within same dir
77
+ renameSync(tmp, UPDATE_CACHE_PATH);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ /** Returns whether banner emission is currently allowed (TTY + no opt-out). */
85
+ export function isUpdateBannerAllowed() {
86
+ if (process.env.ECHOPAI_DISABLE_UPDATE_CHECK)
87
+ return false;
88
+ if (process.env.CI)
89
+ return false;
90
+ // banner goes to stderr; only emit if user is at an interactive terminal
91
+ if (!process.stderr.isTTY)
92
+ return false;
93
+ return true;
94
+ }
95
+ /**
96
+ * Emit a single-line banner to stderr if cache shows a newer version.
97
+ * Idempotent within a single process: only emits once even if called many
98
+ * times (e.g. from both executeVerb and welcome screen).
99
+ */
100
+ let bannerEmittedThisProcess = false;
101
+ export function maybeEmitUpdateBanner(stream = process.stderr) {
102
+ if (bannerEmittedThisProcess)
103
+ return;
104
+ if (!isUpdateBannerAllowed())
105
+ return;
106
+ const info = readCachedUpdate();
107
+ if (!info)
108
+ return;
109
+ if (!isNewer(info.latest, CLI_VERSION))
110
+ return;
111
+ bannerEmittedThisProcess = true;
112
+ // dim cyan, single line, prefixed with sparkles
113
+ const dim = "\x1b[2m";
114
+ const reset = "\x1b[0m";
115
+ stream.write(`${dim}✨ echopai v${info.latest} available (you have v${CLI_VERSION}) — run \`echopai upgrade\`${reset}\n`);
116
+ }
117
+ /** Test hook: reset the once-per-process gate. */
118
+ export function _resetBannerGateForTests() {
119
+ bannerEmittedThisProcess = false;
120
+ }
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Detached update-check worker. Spawned by `bin.ts` at startup
4
+ * with `detached: true, stdio: 'ignore'` + `child.unref()` so the parent
5
+ * exits immediately while this process finishes the registry fetch in the
6
+ * background.
7
+ *
8
+ * Contract:
9
+ * - argv[2] = current CLI version (informational, stored in cache)
10
+ * - reads cache mtime; if < 24h old, exits 0 without network
11
+ * - else GET https://registry.npmjs.org/echopai/latest with 5s timeout,
12
+ * parses { version }, writes ~/.config/echopai/update_cache.json
13
+ * - all errors swallowed (worker exit 0); next invocation retries
14
+ *
15
+ * Never writes to stdout/stderr. Never throws. Cap process time at ~6s
16
+ * via AbortController to avoid lingering processes on flaky networks.
17
+ */
18
+ import { statSync } from "node:fs";
19
+ import { fetch } from "undici";
20
+ import { UPDATE_CACHE_PATH, writeCachedUpdate, } from "./update_check.js";
21
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24h
22
+ const FETCH_TIMEOUT_MS = 5_000;
23
+ const REGISTRY_URL = "https://registry.npmjs.org/echopai/latest";
24
+ async function main() {
25
+ const current = process.argv[2] ?? "0.0.0";
26
+ // Skip if cache fresh
27
+ try {
28
+ const stat = statSync(UPDATE_CACHE_PATH);
29
+ if (Date.now() - stat.mtimeMs < CACHE_TTL_MS)
30
+ return;
31
+ }
32
+ catch {
33
+ // missing cache — proceed to fetch
34
+ }
35
+ const ac = new AbortController();
36
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
37
+ try {
38
+ const res = await fetch(REGISTRY_URL, {
39
+ headers: { Accept: "application/json" },
40
+ signal: ac.signal,
41
+ });
42
+ if (!res.ok)
43
+ return;
44
+ const body = (await res.json());
45
+ const latest = body?.version;
46
+ if (!latest || typeof latest !== "string")
47
+ return;
48
+ const info = {
49
+ checked_at: new Date().toISOString(),
50
+ current,
51
+ latest,
52
+ };
53
+ writeCachedUpdate(info);
54
+ }
55
+ catch {
56
+ // network / abort / JSON parse — silent
57
+ }
58
+ finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+ // Don't propagate errors; this worker is best-effort.
63
+ main().catch(() => { });
@@ -12,6 +12,7 @@
12
12
  import { resolveCredentials, AuthMissingError } from "./auth.js";
13
13
  import { CallApiError } from "./errors.js";
14
14
  import { isTtyHuman, renderError } from "./tty.js";
15
+ import { maybeEmitUpdateBanner } from "./update_check.js";
15
16
  import { CLI_VERSION } from "../version.js";
16
17
  /**
17
18
  * Run a verb handler end-to-end:
@@ -38,6 +39,7 @@ export async function executeVerb(handler) {
38
39
  cliVersion: CLI_VERSION,
39
40
  });
40
41
  process.stdout.write(JSON.stringify(env) + "\n");
42
+ maybeEmitUpdateBanner();
41
43
  process.exit(0);
42
44
  }
43
45
  catch (e) {
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `echopai upgrade` — explicit "check for + show upgrade command" entry.
3
+ *
4
+ * Three flavors:
5
+ * - `echopai upgrade` → 用 cache(无网络)+ 打印 envelope
6
+ * (current / latest / command)
7
+ * - `echopai upgrade --check` → 强制走 npm registry,刷新 cache
8
+ * - `echopai upgrade --check --exec` → 刷新 cache 后真跑 `npm i -g echopai@latest`
9
+ * (透传 stdio;非 0 退出码传递)
10
+ *
11
+ * AI-first envelope on stdout(带 meta.checked_at),错误走 stderr JSON。
12
+ * 永远不会 exec npm 除非显式 --exec。
13
+ */
14
+ import { spawnSync } from "node:child_process";
15
+ import { Command, Option } from "commander";
16
+ import { fetch } from "undici";
17
+ import { CLI_VERSION, } from "../version.js";
18
+ import { isNewer, readCachedUpdate, writeCachedUpdate, } from "../runtime/update_check.js";
19
+ const REGISTRY_URL = "https://registry.npmjs.org/echopai/latest";
20
+ const FETCH_TIMEOUT_MS = 5_000;
21
+ async function fetchLatest() {
22
+ const ac = new AbortController();
23
+ const timer = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);
24
+ try {
25
+ const res = await fetch(REGISTRY_URL, {
26
+ headers: { Accept: "application/json" },
27
+ signal: ac.signal,
28
+ });
29
+ if (!res.ok)
30
+ return null;
31
+ const body = (await res.json());
32
+ return typeof body?.version === "string" ? body.version : null;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ finally {
38
+ clearTimeout(timer);
39
+ }
40
+ }
41
+ function emitJson(data, meta = {}) {
42
+ process.stdout.write(JSON.stringify({ data, meta }) + "\n");
43
+ }
44
+ function emitErr(code, message, exit = 2) {
45
+ process.stderr.write(JSON.stringify({ error: { code, message } }) + "\n");
46
+ process.exit(exit);
47
+ }
48
+ export function buildUpgradeCommand() {
49
+ const cmd = new Command("upgrade").description("Check for + show install command for the latest echopai release (no auto-exec unless --exec).");
50
+ cmd.addOption(new Option("--check", "Force fresh registry fetch (skip 24h cache)"));
51
+ cmd.addOption(new Option("--exec", "Actually run `npm i -g echopai@latest` (default: print command only)"));
52
+ cmd.action(async (opts) => {
53
+ let info = opts.check ? null : readCachedUpdate();
54
+ if (!info || opts.check) {
55
+ const latest = await fetchLatest();
56
+ if (!latest) {
57
+ emitErr("network_error", "Failed to reach npm registry (timeout or non-200). Try again later.");
58
+ }
59
+ info = {
60
+ checked_at: new Date().toISOString(),
61
+ current: CLI_VERSION,
62
+ latest,
63
+ };
64
+ writeCachedUpdate(info);
65
+ }
66
+ const newer = isNewer(info.latest, CLI_VERSION);
67
+ const installCmd = "npm i -g echopai@latest";
68
+ if (!newer) {
69
+ emitJson({
70
+ current: CLI_VERSION,
71
+ latest: info.latest,
72
+ status: "up_to_date",
73
+ message: `Already on the latest version (v${CLI_VERSION}).`,
74
+ }, { checked_at: info.checked_at, source: opts.check ? "registry" : "cache" });
75
+ process.exit(0);
76
+ }
77
+ // Newer available
78
+ if (opts.exec) {
79
+ // Run npm i -g with inherited stdio so user sees real-time progress.
80
+ // Don't shell-out — use direct args to avoid quoting issues.
81
+ const r = spawnSync("npm", ["i", "-g", `echopai@${info.latest}`], { stdio: "inherit" });
82
+ if (r.status === 0) {
83
+ emitJson({
84
+ current: CLI_VERSION,
85
+ latest: info.latest,
86
+ status: "upgraded",
87
+ message: `Upgraded to v${info.latest}. Re-run any command to load the new version.`,
88
+ }, { checked_at: info.checked_at, exec: true });
89
+ process.exit(0);
90
+ }
91
+ emitErr("exec_failed", `npm i -g failed with exit ${r.status ?? "unknown"}. Run manually: ${installCmd}`);
92
+ }
93
+ emitJson({
94
+ current: CLI_VERSION,
95
+ latest: info.latest,
96
+ status: "update_available",
97
+ command: installCmd,
98
+ message: `v${info.latest} is available. Run: ${installCmd} (or pass --exec to run it now)`,
99
+ }, { checked_at: info.checked_at, source: opts.check ? "registry" : "cache" });
100
+ process.exit(0);
101
+ });
102
+ return cmd;
103
+ }