@jackwener/opencli 1.5.4 → 1.5.5

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/tui.js CHANGED
@@ -4,6 +4,7 @@
4
4
  * Uses raw stdin mode + ANSI escape codes for interactive prompts.
5
5
  */
6
6
  import chalk from 'chalk';
7
+ import { EXIT_CODES } from './errors.js';
7
8
  /**
8
9
  * Interactive multi-select checkbox prompt.
9
10
  *
@@ -130,7 +131,7 @@ export async function checkboxPrompt(items, opts = {}) {
130
131
  // Ctrl+C — exit process
131
132
  if (key === '\x03') {
132
133
  cleanup();
133
- process.exit(130);
134
+ process.exit(EXIT_CODES.INTERRUPTED);
134
135
  }
135
136
  }
136
137
  stdin.on('data', onData);
@@ -1,15 +1,19 @@
1
1
  # 新浪财经 (Sina Finance)
2
2
 
3
- **Mode**: 🌐 Public · **Domain**: `finance.sina.com.cn`
3
+ **Mode**: 🌐 Public / 🔐 Browser · **Domain**: `finance.sina.com.cn`
4
4
 
5
5
  ## Commands
6
6
 
7
- | Command | Description |
8
- |---------|-------------|
9
- | `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 |
7
+ | Command | Description | Mode |
8
+ |---------|-------------|------|
9
+ | `opencli sinafinance news` | 新浪财经 7×24 小时实时快讯 | 🌐 Public |
10
+ | `opencli sinafinance rolling-news` | 新浪财经滚动新闻 | 🔐 Browser |
11
+ | `opencli sinafinance stock` | 新浪财经行情(A股/港股/美股) | 🌐 Public |
10
12
 
11
13
  ## Usage Examples
12
14
 
15
+ ### news - 7×24 实时快讯
16
+
13
17
  ```bash
14
18
  # Latest financial news
15
19
  opencli sinafinance news --limit 20
@@ -23,13 +27,59 @@ opencli sinafinance news --type 6 # 国际
23
27
  opencli sinafinance news -f json
24
28
  ```
25
29
 
26
- ### Options
30
+ ### rolling-news - 滚动新闻
31
+
32
+ ```bash
33
+ # Rolling news feed
34
+ opencli sinafinance rolling-news
35
+
36
+ # JSON output
37
+ opencli sinafinance rolling-news -f json
38
+ ```
39
+
40
+ ### stock - 股票行情
41
+
42
+ ```bash
43
+ # Search and view A-share stock
44
+ opencli sinafinance stock 贵州茅台 --market cn
45
+
46
+ # Search and view HK stock
47
+ opencli sinafinance stock 腾讯控股 --market hk
48
+
49
+ # Search and view US stock
50
+ opencli sinafinance stock aapl --market us
51
+
52
+ # Auto-detect market (searches cn, hk, us in order)
53
+ opencli sinafinance stock 招商证券
54
+
55
+ # JSON output
56
+ opencli sinafinance stock 贵州茅台 -f json
57
+ ```
58
+
59
+ ## Options
60
+
61
+ ### news
27
62
 
28
63
  | Option | Description |
29
64
  |--------|-------------|
30
65
  | `--limit` | Max results, up to 50 (default: 20) |
31
66
  | `--type` | News type: `0`=全部, `1`=A股, `2`=宏观, `3`=公司, `4`=数据, `5`=市场, `6`=国际, `7`=观点, `8`=央行, `9`=其它 |
32
67
 
68
+ ### stock
69
+
70
+ | Option | Description |
71
+ |--------|-------------|
72
+ | `--market` | Market: `cn`, `hk`, `us`, `auto` (default: auto). When `auto`, searches in cn, hk, us order |
73
+
33
74
  ## Prerequisites
34
75
 
35
- - No browser required — uses public API
76
+ - `news` & `stock`: No browser required — uses public API
77
+ - `rolling-news`: Chrome running and **logged into** `finance.sina.com.cn`
78
+ - For `rolling-news`: [Browser Bridge extension](/guide/browser-bridge) installed
79
+
80
+ ## Notes
81
+
82
+ - `news` and `stock` use public APIs — no browser or login needed
83
+ - `stock` supports Chinese names, Chinese codes, and ticker symbols; auto-detects market
84
+ - Market priority for auto-detection: cn (A股) → hk (港股) → us (美股)
85
+ - US stock `High`/`Low` columns show 52-week range; A股/港股 show today's range
@@ -238,8 +238,7 @@ async function getAutomationWindow(workspace) {
238
238
  focused: false,
239
239
  width: 1280,
240
240
  height: 900,
241
- type: "normal",
242
- state: "normal"
241
+ type: "normal"
243
242
  });
244
243
  const session = {
245
244
  windowId: win.id,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "OpenCLI",
4
- "version": "1.5.4",
4
+ "version": "1.5.5",
5
5
  "description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencli-extension",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "scripts": {
@@ -160,13 +160,14 @@ async function getAutomationWindow(workspace: string): Promise<number> {
160
160
 
161
161
  // Create a new window with a data: URI that New Tab Override extensions cannot intercept.
162
162
  // Using about:blank would be hijacked by extensions like "New Tab Override".
163
+ // Note: Do NOT set `state` parameter here. Chrome 146+ rejects 'normal' as an invalid
164
+ // state value for windows.create(). The window defaults to 'normal' state anyway.
163
165
  const win = await chrome.windows.create({
164
166
  url: BLANK_PAGE,
165
167
  focused: false,
166
168
  width: 1280,
167
169
  height: 900,
168
170
  type: 'normal',
169
- state: 'normal',
170
171
  });
171
172
  const session: AutomationSession = {
172
173
  windowId: win.id!,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jackwener/opencli",
3
- "version": "1.5.4",
3
+ "version": "1.5.5",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { PKG_VERSION } from './version.js';
15
15
  import { printCompletionScript } from './completion.js';
16
16
  import { loadExternalClis, executeExternalCli, installExternalCli, registerExternalCli, isBinaryInstalled } from './external.js';
17
17
  import { registerAllCommands } from './commanderAdapter.js';
18
- import { getErrorMessage } from './errors.js';
18
+ import { EXIT_CODES, getErrorMessage } from './errors.js';
19
19
 
20
20
  export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
21
21
  const program = new Command();
@@ -120,7 +120,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
120
120
  const { verifyClis, renderVerifyReport } = await import('./verify.js');
121
121
  const r = await verifyClis({ builtinClis: BUILTIN_CLIS, userClis: USER_CLIS, target, smoke: opts.smoke });
122
122
  console.log(renderVerifyReport(r));
123
- process.exitCode = r.ok ? 0 : 1;
123
+ process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
124
124
  });
125
125
 
126
126
  // ── Built-in: explore / synthesize / generate / cascade ───────────────────
@@ -180,7 +180,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
180
180
  workspace,
181
181
  });
182
182
  console.log(renderGenerateSummary(r));
183
- process.exitCode = r.ok ? 0 : 1;
183
+ process.exitCode = r.ok ? EXIT_CODES.SUCCESS : EXIT_CODES.GENERIC_ERROR;
184
184
  });
185
185
 
186
186
  // ── Built-in: record ─────────────────────────────────────────────────────
@@ -204,7 +204,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
204
204
  timeoutMs: parseInt(opts.timeout, 10),
205
205
  });
206
206
  console.log(renderRecordSummary(result));
207
- process.exitCode = result.candidateCount > 0 ? 0 : 1;
207
+ process.exitCode = result.candidateCount > 0 ? EXIT_CODES.SUCCESS : EXIT_CODES.EMPTY_RESULT;
208
208
  });
209
209
 
210
210
  program
@@ -272,7 +272,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
272
272
  }
273
273
  } catch (err) {
274
274
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
275
- process.exitCode = 1;
275
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
276
276
  }
277
277
  });
278
278
 
@@ -287,7 +287,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
287
287
  console.log(chalk.green(`✅ Plugin "${name}" uninstalled.`));
288
288
  } catch (err) {
289
289
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
290
- process.exitCode = 1;
290
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
291
291
  }
292
292
  });
293
293
 
@@ -299,12 +299,12 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
299
299
  .action(async (name: string | undefined, opts: { all?: boolean }) => {
300
300
  if (!name && !opts.all) {
301
301
  console.error(chalk.red('Error: Please specify a plugin name or use the --all flag.'));
302
- process.exitCode = 1;
302
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
303
303
  return;
304
304
  }
305
305
  if (name && opts.all) {
306
306
  console.error(chalk.red('Error: Cannot specify both a plugin name and --all.'));
307
- process.exitCode = 1;
307
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
308
308
  return;
309
309
  }
310
310
 
@@ -335,7 +335,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
335
335
  console.log();
336
336
  if (hasErrors) {
337
337
  console.error(chalk.red('Completed with some errors.'));
338
- process.exitCode = 1;
338
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
339
339
  } else {
340
340
  console.log(chalk.green('✅ All plugins updated successfully.'));
341
341
  }
@@ -348,7 +348,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
348
348
  console.log(chalk.green(`✅ Plugin "${name}" updated successfully.`));
349
349
  } catch (err) {
350
350
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
351
- process.exitCode = 1;
351
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
352
352
  }
353
353
  });
354
354
 
@@ -438,7 +438,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
438
438
  console.log(chalk.dim(` opencli ${name} hello`));
439
439
  } catch (err) {
440
440
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
441
- process.exitCode = 1;
441
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
442
442
  }
443
443
  });
444
444
 
@@ -454,7 +454,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
454
454
  const ext = externalClis.find(e => e.name === name);
455
455
  if (!ext) {
456
456
  console.error(chalk.red(`External CLI '${name}' not found in registry.`));
457
- process.exitCode = 1;
457
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
458
458
  return;
459
459
  }
460
460
  installExternalCli(ext);
@@ -480,7 +480,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
480
480
  executeExternalCli(name, args, externalClis);
481
481
  } catch (err) {
482
482
  console.error(chalk.red(`Error: ${getErrorMessage(err)}`));
483
- process.exitCode = 1;
483
+ process.exitCode = EXIT_CODES.GENERIC_ERROR;
484
484
  }
485
485
  }
486
486
 
@@ -525,7 +525,7 @@ export function runCli(BUILTIN_CLIS: string, USER_CLIS: string): void {
525
525
  console.error(chalk.dim(` Tip: '${binary}' exists on your PATH. Use 'opencli register ${binary}' to add it as an external CLI.`));
526
526
  }
527
527
  program.outputHelp();
528
- process.exitCode = 1;
528
+ process.exitCode = EXIT_CODES.USAGE_ERROR;
529
529
  });
530
530
 
531
531
  program.parse();
@@ -13,7 +13,7 @@
13
13
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
14
14
  import { CDPBridge } from '../../browser/cdp.js';
15
15
  import type { IPage } from '../../types.js';
16
- import { getErrorMessage } from '../../errors.js';
16
+ import { EXIT_CODES, getErrorMessage } from '../../errors.js';
17
17
 
18
18
  // ─── Types ───────────────────────────────────────────────────────────
19
19
 
@@ -594,7 +594,7 @@ export async function startServe(opts: { port?: number } = {}): Promise<void> {
594
594
  console.error('\n[serve] Shutting down...');
595
595
  cdp?.close().catch(() => {});
596
596
  server.close();
597
- process.exit(0);
597
+ process.exit(EXIT_CODES.SUCCESS);
598
598
  };
599
599
  process.on('SIGTERM', shutdown);
600
600
  process.on('SIGINT', shutdown);
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Sinafinance rolling news feed
3
+ */
4
+
5
+ import { cli, Strategy } from '../../registry.js';
6
+
7
+ cli({
8
+ site: 'sinafinance',
9
+ name: 'rolling-news',
10
+ description: '新浪财经滚动新闻',
11
+ domain: 'finance.sina.com.cn/roll',
12
+ strategy: Strategy.COOKIE,
13
+ args: [],
14
+ columns: ['column', 'title', 'date', 'url'],
15
+ func: async (page, _args) => {
16
+ await page.goto(`https://finance.sina.com.cn/roll/#pageid=384&lid=2519`);
17
+ await page.wait({ selector: '.d_list_txt li', timeout: 10000 });
18
+
19
+ const payload = await page.evaluate(`
20
+ (() => {
21
+ const cleanText = (value) => (value || '').replace(/\\s+/g, ' ').trim();
22
+ const results = [];
23
+ document.querySelectorAll('.d_list_txt li').forEach(el => {
24
+ const titleEl = el.querySelector('.c_tit a');
25
+ const columnEl = el.querySelector('.c_chl');
26
+ const dateEl = el.querySelector('.c_time');
27
+ const url = titleEl?.getAttribute('href') || '';
28
+ if (!url) return;
29
+ results.push({
30
+ title: cleanText(titleEl?.textContent || ''),
31
+ column: cleanText(columnEl?.textContent || ''),
32
+ date: cleanText(dateEl?.textContent || ''),
33
+ url: url,
34
+ });
35
+ });
36
+ return results;
37
+ })()
38
+ `);
39
+ if (!Array.isArray(payload)) return [];
40
+ return payload;
41
+ },
42
+ });
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Sinafinance stock quote — A股 / 港股 / 美股
3
+ *
4
+ * Uses two public Sina APIs (no browser required):
5
+ * suggest3.sinajs.cn — symbol search
6
+ * hq.sinajs.cn — real-time quote
7
+ */
8
+
9
+ import { cli, Strategy } from '../../registry.js';
10
+ import { CliError } from '../../errors.js';
11
+
12
+ const MARKET_CN = '11';
13
+ const MARKET_HK = '31';
14
+ const MARKET_US = '41';
15
+
16
+ async function fetchGBK(url: string): Promise<string> {
17
+ const res = await fetch(url, { headers: { Referer: 'https://finance.sina.com.cn' } });
18
+ if (!res.ok) throw new CliError('FETCH_ERROR', `Sina API HTTP ${res.status}`, 'Check your network');
19
+ const buf = await res.arrayBuffer();
20
+ return new TextDecoder('gbk').decode(buf);
21
+ }
22
+
23
+ interface SuggestEntry { name: string; market: string; symbol: string; }
24
+
25
+ function parseSuggest(raw: string, markets: string[]): SuggestEntry[] {
26
+ const m = raw.match(/suggestvalue="(.*)"/s);
27
+ if (!m) return [];
28
+ return m[1].split(';').filter(Boolean).map(s => {
29
+ const p = s.split(',');
30
+ return { name: p[4] || p[0] || '', market: p[1] || '', symbol: p[3] || '' };
31
+ }).filter(e => markets.includes(e.market));
32
+ }
33
+
34
+ function hqSymbol(e: SuggestEntry): string {
35
+ if (e.market === MARKET_HK) return `hk${e.symbol}`;
36
+ if (e.market === MARKET_US) return `gb_${e.symbol}`;
37
+ return e.symbol; // A股: already "sh600519" / "sz300XXX"
38
+ }
39
+
40
+ function parseHq(raw: string, sym: string): string[] {
41
+ const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
42
+ const m = raw.match(new RegExp(`hq_str_${escaped}="([^"]*)"`));
43
+ return m ? m[1].split(',') : [];
44
+ }
45
+
46
+ function fmtMktCap(val: string): string {
47
+ const n = parseFloat(val);
48
+ if (!n) return '';
49
+ if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T';
50
+ if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B';
51
+ if (n >= 1e6) return (n / 1e6).toFixed(2) + 'M';
52
+ return String(n);
53
+ }
54
+
55
+ cli({
56
+ site: 'sinafinance',
57
+ name: 'stock',
58
+ description: '新浪财经行情(A股/港股/美股)',
59
+ domain: 'suggest3.sinajs.cn,hq.sinajs.cn',
60
+ strategy: Strategy.PUBLIC,
61
+ browser: false,
62
+ args: [
63
+ { name: 'key', type: 'string', required: true, positional: true, help: 'Stock name or code (e.g. 贵州茅台, 腾讯控股, AAPL)' },
64
+ { name: 'market', type: 'string', default: 'auto', help: 'Market: cn, hk, us, auto (default: auto searches cn → hk → us)' },
65
+ ],
66
+ columns: ['Symbol', 'Name', 'Price', 'Change', 'ChangePercent', 'Open', 'High', 'Low', 'Volume', 'MarketCap'],
67
+ func: async (_page, args) => {
68
+ const key = String(args.key);
69
+ const market = String(args.market);
70
+
71
+ const marketMap: Record<string, string[]> = {
72
+ cn: [MARKET_CN], hk: [MARKET_HK], us: [MARKET_US],
73
+ auto: [MARKET_CN, MARKET_HK, MARKET_US],
74
+ };
75
+ const targetMarkets = marketMap[market];
76
+ if (!targetMarkets) {
77
+ throw new CliError('INPUT_ERROR', `Invalid market: "${market}"`, 'Expected cn, hk, us, or auto');
78
+ }
79
+
80
+ // 1. Search symbol — only request the markets we care about
81
+ const suggestRaw = await fetchGBK(
82
+ `https://suggest3.sinajs.cn/suggest/type=${targetMarkets.join(',')}&key=${encodeURIComponent(key)}`
83
+ );
84
+ const entries = parseSuggest(suggestRaw, targetMarkets);
85
+ if (!entries.length) {
86
+ throw new CliError('NOT_FOUND', `No stock found for "${key}"`, 'Try a different name, code, or --market');
87
+ }
88
+
89
+ // Pick best match: score by name similarity, tiebreak by market priority
90
+ const needle = key.toLowerCase();
91
+ const score = (e: SuggestEntry): number => {
92
+ const n = e.name.toLowerCase();
93
+ if (n === needle) return 1;
94
+ if (n.includes(needle)) return needle.length / n.length;
95
+ return 0;
96
+ };
97
+ const best = entries.sort((a, b) => {
98
+ const d = score(b) - score(a);
99
+ return d !== 0 ? d : targetMarkets.indexOf(a.market) - targetMarkets.indexOf(b.market);
100
+ })[0];
101
+
102
+ // 2. Fetch quote
103
+ const sym = hqSymbol(best);
104
+ const hqRaw = await fetchGBK(`https://hq.sinajs.cn/list=${sym}`);
105
+ const f = parseHq(hqRaw, sym);
106
+
107
+ if (f.length < 2 || !f[0]) {
108
+ throw new CliError('NOT_FOUND', `No quote data for "${key}"`, 'Market may be closed or data unavailable');
109
+ }
110
+
111
+ if (best.market === MARKET_CN) {
112
+ const price = parseFloat(f[3]);
113
+ const prev = parseFloat(f[2]);
114
+ const chg = (price - prev).toFixed(2);
115
+ const chgPct = ((price - prev) / prev * 100).toFixed(2) + '%';
116
+ return [{ Symbol: sym.toUpperCase(), Name: f[0], Price: f[3], Change: chg, ChangePercent: chgPct, Open: f[1], High: f[4], Low: f[5], Volume: f[8], MarketCap: '' }];
117
+ }
118
+
119
+ if (best.market === MARKET_HK) {
120
+ // [2]=price [4]=high [5]=low [6]=open [7]=change [8]=change% [11]=volume
121
+ return [{ Symbol: best.symbol, Name: f[1], Price: f[2], Change: f[7], ChangePercent: f[8] + '%', Open: f[6], High: f[4], Low: f[5], Volume: f[11], MarketCap: '' }];
122
+ }
123
+
124
+ // MARKET_US: [1]=price [2]=change% [4]=change [6]=open [7]=today_low [8]=52wH [9]=52wL [10]=volume [12]=mktcap
125
+ return [{ Symbol: best.symbol.toUpperCase(), Name: f[0], Price: f[1], Change: f[4], ChangePercent: f[2] + '%', Open: f[6], High: f[8], Low: f[9], Volume: f[10], MarketCap: fmtMktCap(f[12]) }];
126
+ },
127
+ });
@@ -18,6 +18,7 @@ import { render as renderOutput } from './output.js';
18
18
  import { executeCommand } from './execution.js';
19
19
  import {
20
20
  CliError,
21
+ EXIT_CODES,
21
22
  ERROR_ICONS,
22
23
  getErrorMessage,
23
24
  BrowserConnectError,
@@ -40,7 +41,7 @@ export function normalizeArgValue(argType: string | undefined, value: unknown, n
40
41
  if (normalized === 'true') return true;
41
42
  if (normalized === 'false') return false;
42
43
 
43
- throw new CliError('ARGUMENT', `"${name}" must be either "true" or "false".`);
44
+ throw new ArgumentError(`"${name}" must be either "true" or "false".`);
44
45
  }
45
46
 
46
47
  /**
@@ -117,11 +118,33 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi
117
118
  });
118
119
  } catch (err) {
119
120
  await renderError(err, fullName(cmd), optionsRecord.verbose === true);
120
- process.exitCode = 1;
121
+ process.exitCode = resolveExitCode(err);
121
122
  }
122
123
  });
123
124
  }
124
125
 
126
+ // ── Exit code resolution ─────────────────────────────────────────────────────
127
+
128
+ /**
129
+ * Map any thrown value to a Unix process exit code.
130
+ *
131
+ * - CliError subclasses carry their own exitCode (set in errors.ts).
132
+ * - Generic Error objects are classified by message pattern so that
133
+ * un-typed auth / not-found errors from adapters still produce
134
+ * meaningful exit codes for shell scripts.
135
+ */
136
+ function resolveExitCode(err: unknown): number {
137
+ if (err instanceof CliError) return err.exitCode;
138
+
139
+ // Pattern-based fallback for untyped errors thrown by third-party adapters.
140
+ const msg = getErrorMessage(err);
141
+ const kind = classifyGenericError(msg);
142
+ if (kind === 'auth') return EXIT_CODES.NOPERM;
143
+ if (kind === 'not-found') return EXIT_CODES.EMPTY_RESULT;
144
+ if (kind === 'http') return EXIT_CODES.GENERIC_ERROR; // HTTP 4xx/5xx → generic; renderer shows details
145
+ return EXIT_CODES.GENERIC_ERROR;
146
+ }
147
+
125
148
  // ── Error rendering ──────────────────────────────────────────────────────────
126
149
 
127
150
  const ISSUES_URL = 'https://github.com/jackwener/opencli/issues';
package/src/daemon.ts CHANGED
@@ -22,6 +22,7 @@
22
22
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
23
23
  import { WebSocketServer, WebSocket, type RawData } from 'ws';
24
24
  import { DEFAULT_DAEMON_PORT } from './constants.js';
25
+ import { EXIT_CODES } from './errors.js';
25
26
 
26
27
  const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
27
28
  const IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
@@ -53,7 +54,7 @@ function resetIdleTimer(): void {
53
54
  if (idleTimer) clearTimeout(idleTimer);
54
55
  idleTimer = setTimeout(() => {
55
56
  console.error('[daemon] Idle timeout, shutting down');
56
- process.exit(0);
57
+ process.exit(EXIT_CODES.SUCCESS);
57
58
  }, IDLE_TIMEOUT);
58
59
  }
59
60
 
@@ -303,10 +304,10 @@ httpServer.listen(PORT, '127.0.0.1', () => {
303
304
  httpServer.on('error', (err: NodeJS.ErrnoException) => {
304
305
  if (err.code === 'EADDRINUSE') {
305
306
  console.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`);
306
- process.exit(1);
307
+ process.exit(EXIT_CODES.SERVICE_UNAVAIL);
307
308
  }
308
309
  console.error('[daemon] Server error:', err.message);
309
- process.exit(1);
310
+ process.exit(EXIT_CODES.GENERIC_ERROR);
310
311
  });
311
312
 
312
313
  // Graceful shutdown
@@ -319,7 +320,7 @@ function shutdown(): void {
319
320
  pending.clear();
320
321
  if (extensionWs) extensionWs.close();
321
322
  httpServer.close();
322
- process.exit(0);
323
+ process.exit(EXIT_CODES.SUCCESS);
323
324
  }
324
325
 
325
326
  process.on('SIGTERM', shutdown);