echopai 2.0.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.
Files changed (45) hide show
  1. package/README.md +386 -0
  2. package/dist/_generated/commands.js +274 -0
  3. package/dist/_generated/help.js +190 -0
  4. package/dist/_generated/operations.js +1306 -0
  5. package/dist/bin.js +170 -0
  6. package/dist/runtime/auth.js +95 -0
  7. package/dist/runtime/envelope.js +52 -0
  8. package/dist/runtime/errors.js +186 -0
  9. package/dist/runtime/filters.js +153 -0
  10. package/dist/runtime/format.js +143 -0
  11. package/dist/runtime/http.js +65 -0
  12. package/dist/runtime/idempotency.js +18 -0
  13. package/dist/runtime/invoker.js +387 -0
  14. package/dist/runtime/io.js +16 -0
  15. package/dist/runtime/paginator.js +146 -0
  16. package/dist/runtime/trace.js +99 -0
  17. package/dist/runtime/tty.js +51 -0
  18. package/dist/runtime/verb_cmd.js +70 -0
  19. package/dist/runtime/verb_runner.js +152 -0
  20. package/dist/runtime/whoami_cache.js +109 -0
  21. package/dist/tools/api.js +81 -0
  22. package/dist/tools/completion.js +116 -0
  23. package/dist/tools/config.js +123 -0
  24. package/dist/tools/doctor.js +183 -0
  25. package/dist/tools/login.js +99 -0
  26. package/dist/tools/mcp.js +141 -0
  27. package/dist/tools/raw.js +96 -0
  28. package/dist/tools/schema.js +58 -0
  29. package/dist/tools/trace.js +54 -0
  30. package/dist/tools/whoami.js +132 -0
  31. package/dist/verbs/_spec.js +15 -0
  32. package/dist/verbs/bars_batch.js +66 -0
  33. package/dist/verbs/chart.js +110 -0
  34. package/dist/verbs/digest.js +342 -0
  35. package/dist/verbs/hot.js +29 -0
  36. package/dist/verbs/index.js +49 -0
  37. package/dist/verbs/lookup.js +72 -0
  38. package/dist/verbs/news.js +67 -0
  39. package/dist/verbs/quote.js +53 -0
  40. package/dist/verbs/research.js +44 -0
  41. package/dist/verbs/scan.js +42 -0
  42. package/dist/verbs/sentiment.js +46 -0
  43. package/dist/verbs/views.js +83 -0
  44. package/dist/version.js +5 -0
  45. package/package.json +58 -0
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Output format renderer。
3
+ *
4
+ * - json JSON.stringify (multi-line in TTY, single-line otherwise)
5
+ * - ndjson one JSON object per line(流式友好)
6
+ * - table ASCII table(仅 array of flat object)
7
+ * - csv RFC-4180 quoted
8
+ * - tsv tab-separated
9
+ * - yaml minimal YAML(不依赖 lib,仅 flat / array of flat)
10
+ *
11
+ * 自动从 data 推断列名(取 array[0] 的 keys);若不是 array of object 则
12
+ * fallback 到 json(避免格式错误)。
13
+ */
14
+ export function isOutputFormat(s) {
15
+ return ["json", "ndjson", "table", "csv", "tsv", "yaml"].includes(s);
16
+ }
17
+ export function render(env, fmt, isTty) {
18
+ if (fmt === "json") {
19
+ // TTY: pretty 2-space; non-TTY: single line (NDJSON-friendly pipelines)
20
+ return JSON.stringify(env, null, isTty ? 2 : 0);
21
+ }
22
+ if (fmt === "ndjson") {
23
+ return ndjson(env);
24
+ }
25
+ // table / csv / tsv / yaml — only useful on array of flat object data。
26
+ // server envelope 常见形态 {items: [...], total, has_more} —— 对 items 表格化。
27
+ let target = env.data;
28
+ if (typeof target === "object" &&
29
+ target !== null &&
30
+ !Array.isArray(target) &&
31
+ Array.isArray(target.items)) {
32
+ target = target.items;
33
+ }
34
+ const arr = arrayOfFlatObjects(target);
35
+ if (arr === null) {
36
+ // fallback to json
37
+ return JSON.stringify(env, null, isTty ? 2 : 0);
38
+ }
39
+ if (fmt === "table")
40
+ return renderTable(arr);
41
+ if (fmt === "csv")
42
+ return renderDelimited(arr, ",", true);
43
+ if (fmt === "tsv")
44
+ return renderDelimited(arr, "\t", false);
45
+ if (fmt === "yaml")
46
+ return renderYaml(arr);
47
+ return JSON.stringify(env, null, isTty ? 2 : 0);
48
+ }
49
+ function arrayOfFlatObjects(data) {
50
+ if (!Array.isArray(data))
51
+ return null;
52
+ if (data.length === 0)
53
+ return [];
54
+ // 允许嵌套字段(cell 渲染时 JSON.stringify),但顶层每条必须是 object。
55
+ for (const item of data) {
56
+ if (typeof item !== "object" || item === null || Array.isArray(item))
57
+ return null;
58
+ }
59
+ return data;
60
+ }
61
+ /**
62
+ * Pick a stable subset of columns for table / CSV — the union of keys from
63
+ * the first few rows, capped at COLUMN_LIMIT. 防超宽表格 + 缺字段 row 不破列。
64
+ */
65
+ const COLUMN_LIMIT = 8;
66
+ function pickColumns(arr) {
67
+ const seen = new Set();
68
+ for (let i = 0; i < Math.min(arr.length, 5); i++) {
69
+ for (const k of Object.keys(arr[i])) {
70
+ if (!seen.has(k)) {
71
+ seen.add(k);
72
+ if (seen.size >= COLUMN_LIMIT)
73
+ return [...seen];
74
+ }
75
+ }
76
+ }
77
+ return [...seen];
78
+ }
79
+ function ndjson(env) {
80
+ if (Array.isArray(env.data)) {
81
+ return env.data.map((d) => JSON.stringify(d)).join("\n");
82
+ }
83
+ return JSON.stringify(env);
84
+ }
85
+ function renderTable(arr) {
86
+ if (arr.length === 0)
87
+ return "(empty)";
88
+ const cols = pickColumns(arr);
89
+ const CELL_MAX = 60; // truncate long cells (URLs, content)
90
+ const truncate = (s) => (s.length > CELL_MAX ? s.slice(0, CELL_MAX - 1) + "…" : s);
91
+ const rows = arr.map((r) => cols.map((c) => truncate(stringify(r[c]))));
92
+ const widths = cols.map((c, i) => Math.max(c.length, ...rows.map((r) => r[i].length)));
93
+ const sep = widths.map((w) => "-".repeat(w)).join("-+-");
94
+ const headerRow = cols.map((c, i) => c.padEnd(widths[i])).join(" | ");
95
+ const dataRows = rows.map((r) => r.map((cell, i) => cell.padEnd(widths[i])).join(" | "));
96
+ return [headerRow, sep, ...dataRows].join("\n");
97
+ }
98
+ function renderDelimited(arr, delim, csvQuote) {
99
+ if (arr.length === 0)
100
+ return "";
101
+ const cols = pickColumns(arr);
102
+ const escape = (s) => {
103
+ if (!csvQuote)
104
+ return s.replace(/\t/g, " ").replace(/\n/g, " ");
105
+ if (s.includes(delim) || s.includes('"') || s.includes("\n")) {
106
+ return `"${s.replace(/"/g, '""')}"`;
107
+ }
108
+ return s;
109
+ };
110
+ const header = cols.map(escape).join(delim);
111
+ const rows = arr.map((r) => cols.map((c) => escape(stringify(r[c]))).join(delim));
112
+ return [header, ...rows].join("\n");
113
+ }
114
+ function renderYaml(arr) {
115
+ if (arr.length === 0)
116
+ return "[]";
117
+ return arr
118
+ .map((r) => {
119
+ const lines = Object.entries(r).map(([k, v]) => ` ${k}: ${yamlScalar(v)}`);
120
+ return ["-", ...lines].join("\n");
121
+ })
122
+ .join("\n");
123
+ }
124
+ function yamlScalar(v) {
125
+ if (v === null || v === undefined)
126
+ return "null";
127
+ if (typeof v === "string") {
128
+ // quote if contains special chars
129
+ if (v === "" || /[:#\n"']|^\s|\s$/.test(v))
130
+ return JSON.stringify(v);
131
+ return v;
132
+ }
133
+ return String(v);
134
+ }
135
+ function stringify(v) {
136
+ if (v === null || v === undefined)
137
+ return "";
138
+ if (typeof v === "string")
139
+ return v;
140
+ if (typeof v === "number" || typeof v === "boolean")
141
+ return String(v);
142
+ return JSON.stringify(v);
143
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Centralized header construction for all CLI outbound requests (HTTP + WS).
3
+ *
4
+ * 强制注入:
5
+ * - Authorization (HTTP only; WS 走 Sec-WebSocket-Protocol subprotocol)
6
+ * - Accept: application/json (HTTP)
7
+ * - User-Agent: echopai-cli/<v> Node/<v> <platform>-<arch>
8
+ * - X-Client: echopai-cli/<v>
9
+ * - X-Client-Channel: cli | mcp (默认 cli;mcp serve 注入 mcp)
10
+ * - X-Request-Id: client-generated uuidv4 (server 若 echo 同名 header 以 server 为准)
11
+ *
12
+ * 每次构造生成新的 X-Request-Id(除非显式传 requestId)。分页 / WS 重连场景每次
13
+ * 迭代都应该重新调一次 builder,以拿到独立的 request_id。
14
+ *
15
+ * 与 envelope.ts 的关系:本模块只负责出站;入站 / meta 拼装由 envelope.ts 做。
16
+ */
17
+ import { randomUUID } from "node:crypto";
18
+ import { Headers } from "undici";
19
+ export function buildUserAgent(cliVersion) {
20
+ // User-Agent identifier matches X-Client (also `echopai-cli/<v>`) so
21
+ // server-side metrics/audit can match on either header uniformly.
22
+ // Renamed from `@echopai/cli/<v>` in v2.0.0 alongside the npm rename
23
+ // from @echopai/cli → echopai. X-Client itself was already this form.
24
+ return `echopai-cli/${cliVersion} Node/${process.version} ${process.platform}-${process.arch}`;
25
+ }
26
+ export function buildHttpHeaders(ctx) {
27
+ const requestId = ctx.requestId ?? randomUUID();
28
+ const headers = new Headers({
29
+ Authorization: `Bearer ${ctx.bearer}`,
30
+ Accept: "application/json",
31
+ "User-Agent": buildUserAgent(ctx.cliVersion),
32
+ "X-Client": `echopai-cli/${ctx.cliVersion}`,
33
+ "X-Client-Channel": ctx.channel ?? "cli",
34
+ "X-Request-Id": requestId,
35
+ });
36
+ return { headers, requestId };
37
+ }
38
+ /**
39
+ * WebSocket upgrade headers. NO Authorization — WS auth flows via
40
+ * Sec-WebSocket-Protocol `bearer.<token>` subprotocol.
41
+ *
42
+ * Plain object because `ws` lib's `headers` option is Record<string,string>,
43
+ * not undici Headers.
44
+ */
45
+ export function buildWsHeaders(ctx) {
46
+ const requestId = ctx.requestId ?? randomUUID();
47
+ const headers = {
48
+ "User-Agent": buildUserAgent(ctx.cliVersion),
49
+ "X-Client": `echopai-cli/${ctx.cliVersion}`,
50
+ "X-Client-Channel": ctx.channel ?? "cli",
51
+ "X-Request-Id": requestId,
52
+ };
53
+ return { headers, requestId };
54
+ }
55
+ /**
56
+ * Effective request_id for meta envelope / logging:
57
+ * server echo wins (server can rewrite for correlation in its own audit);
58
+ * otherwise client-generated uuid is canonical.
59
+ *
60
+ * After Phase 1 lands, both ends will have a request_id even for endpoints
61
+ * the server doesn't echo yet — they just won't agree until server adopts echo.
62
+ */
63
+ export function resolveRequestId(serverHeader, clientGenerated) {
64
+ return serverHeader && serverHeader.length > 0 ? serverHeader : clientGenerated;
65
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Idempotency-Key 自动生成。
3
+ *
4
+ * 端点 op.idempotencyRequired === true 时,invoker 自动 gen UUIDv4 并:
5
+ * - 加 Idempotency-Key request header
6
+ * - echo 到 stderr 让用户 / AI agent 能记下来重试时复用同一 key
7
+ *
8
+ * 用户也可显式 --idempotency-key <uuid> 覆盖(debug 重放用)。
9
+ */
10
+ import * as crypto from "node:crypto";
11
+ export function generateIdempotencyKey() {
12
+ return crypto.randomUUID();
13
+ }
14
+ export function announceKey(key, op) {
15
+ // stderr 不污染 stdout JSON envelope
16
+ process.stderr.write(`[idempotency-key] ${op}: ${key}\n` +
17
+ `[idempotency-key] retry: pass --idempotency-key ${key} to dedupe.\n`);
18
+ }
@@ -0,0 +1,387 @@
1
+ /**
2
+ * 端点调用器:Ajv pre-flight → undici fetch → envelope 解析 → render → exit。
3
+ *
4
+ * 由 _generated/commands.ts 的 dispatch 入口调用。
5
+ *
6
+ * 支持参数:
7
+ * --output <fmt> 覆盖默认 outputDefault
8
+ * --query <jmespath> Phase 3 加,本 PR 不接
9
+ * --debug stderr 打 HTTP wire trace(脱敏 Bearer)
10
+ * --raw 跳 envelope 解析,输出原始 body
11
+ */
12
+ import AjvPkg from "ajv";
13
+ import addFormatsPkg from "ajv-formats";
14
+ import { fetch } from "undici";
15
+ import { resolveCredentials, AuthMissingError } from "./auth.js";
16
+ import { buildResponseEnvelope } from "./envelope.js";
17
+ import { exitCodeForStatus, isRetryableByCode, resolveRecoveryHint, tryParseErrorEnvelope, CallApiError, } from "./errors.js";
18
+ import { applyFilters, parseFieldsFlag, parseMaxBytesFlag, FilterError, } from "./filters.js";
19
+ import { isOutputFormat, render } from "./format.js";
20
+ import { buildHttpHeaders, resolveRequestId } from "./http.js";
21
+ import { appendTrace } from "./trace.js";
22
+ import { generateIdempotencyKey, announceKey } from "./idempotency.js";
23
+ import { writeStdout, writeStderr } from "./io.js";
24
+ import { paginate, exitCodeForPaginateError } from "./paginator.js";
25
+ import { renderError, isTtyHuman } from "./tty.js";
26
+ const Ajv = (AjvPkg.default ??
27
+ AjvPkg);
28
+ const addFormats = (addFormatsPkg.default ??
29
+ addFormatsPkg);
30
+ // strict: false 让 Ajv 接受 OpenAPI 3.0 的 `example` / `examples` 关键字(不是
31
+ // 标准 JSON Schema draft 7,但 OpenAPI 普遍用)。
32
+ // coerceTypes: 'array' 允许 --codes SSE:000001 自动包成 ["SSE:000001"],
33
+ // 也允许 --codes "A,B,C" 字符串入 array schema(但用户更常用单值)。
34
+ const ajv = new Ajv({
35
+ allErrors: true,
36
+ useDefaults: true,
37
+ coerceTypes: "array",
38
+ strict: false,
39
+ });
40
+ addFormats(ajv);
41
+ const SYSTEM_FLAGS = new Set([
42
+ "output",
43
+ "all",
44
+ "key",
45
+ "profile",
46
+ "debug",
47
+ "raw",
48
+ "idempotency_key",
49
+ "max_pages",
50
+ "max_items",
51
+ // PR 1.4 — output filter pipeline (renamed root --query → --jq in Phase 6
52
+ // smoke-fix because subcommand specs declare their own --query as a body
53
+ // param, e.g. news.search. Stripping "query" here would eat that param.)
54
+ "jq",
55
+ "fields",
56
+ "max_bytes",
57
+ // Phase 6 — write safety + dry-run
58
+ "yes",
59
+ "dry_run",
60
+ ]);
61
+ export async function invoke(op, args, ctx) {
62
+ const isTty = process.stdout.isTTY === true && !process.env.CI;
63
+ // 1. resolve --output
64
+ let outputFmt = op.outputDefault;
65
+ if (typeof args.output === "string" && isOutputFormat(args.output)) {
66
+ outputFmt = args.output;
67
+ }
68
+ const debug = Boolean(args.debug);
69
+ const raw = Boolean(args.raw);
70
+ // 2. resolve credentials
71
+ const resolveOpts = {};
72
+ if (typeof args.key === "string")
73
+ resolveOpts.key = args.key;
74
+ if (typeof args.profile === "string")
75
+ resolveOpts.profile = args.profile;
76
+ let creds;
77
+ try {
78
+ creds = resolveCredentials(resolveOpts);
79
+ }
80
+ catch (e) {
81
+ if (e instanceof AuthMissingError) {
82
+ trace(op, { exit_code: 1, error_code: "auth_missing" });
83
+ writeError("auth_missing", e.message, 1, e.recovery_hint ? { recovery_hint: e.recovery_hint } : undefined);
84
+ }
85
+ throw e;
86
+ }
87
+ // 3. strip system flags + undefined to get actual API params
88
+ const apiParams = {};
89
+ for (const [k, v] of Object.entries(args)) {
90
+ if (SYSTEM_FLAGS.has(k))
91
+ continue;
92
+ if (v === undefined || v === "")
93
+ continue;
94
+ apiParams[k] = v;
95
+ }
96
+ // 4a. coerce CSV → array for params declared as array in schema (Ajv coerce
97
+ // wraps single-string as 1-element array, but for "A,B,C" we want split-on-comma).
98
+ for (const [pname, pschema] of Object.entries(op.inputSchema.properties)) {
99
+ if (typeof pschema === "object" &&
100
+ pschema !== null &&
101
+ pschema.type === "array" &&
102
+ typeof apiParams[pname] === "string" &&
103
+ apiParams[pname].includes(",")) {
104
+ apiParams[pname] = apiParams[pname].split(",").map((s) => s.trim());
105
+ }
106
+ }
107
+ // 4b. Ajv pre-flight schema validation
108
+ const validate = ajv.compile(op.inputSchema);
109
+ if (!validate(apiParams)) {
110
+ const errs = (validate.errors || [])
111
+ .map((e) => `${e.instancePath || "(root)"}: ${e.message ?? "invalid"}`)
112
+ .join("; ");
113
+ trace(op, { exit_code: 1, error_code: "invalid_args" });
114
+ writeError("invalid_args", `Input validation failed: ${errs}`, 1);
115
+ }
116
+ // 4c. Phase 6 — write safety + dry-run gates
117
+ //
118
+ // Two CLI-side preflight checks, both fail-closed:
119
+ //
120
+ // a) non-TTY write without --yes → confirmation_required
121
+ // Agents/scripts can never accidentally trigger a side-effect; they
122
+ // must opt in explicitly. TTY users get the usual interactive flow
123
+ // (commander itself prompts? no — we keep this simple: TTY skips the
124
+ // gate and assumes the user typed the command knowingly).
125
+ //
126
+ // b) --dry-run on op where dryRunSupported=false → dry_run_unsupported
127
+ // Refuse to silently noop. The agent gets a structured error and
128
+ // knows to retry without --dry-run (or pick a different op).
129
+ //
130
+ // When --dry-run is honored we set X-Dry-Run:1 in the outgoing headers
131
+ // further down (step 5c). Read ops with --dry-run are a noop client-side
132
+ // (they don't mutate anyway) — we treat them as a soft success but stamp
133
+ // meta.dry_run=true so callers know nothing happened.
134
+ const yes = Boolean(args.yes);
135
+ const dryRun = Boolean(args.dry_run);
136
+ if (op.sideEffect === "write" && !isTty && !yes) {
137
+ trace(op, { exit_code: 1, error_code: "confirmation_required" });
138
+ writeError("confirmation_required", `Operation ${op.cliKey} is a write (side-effect=write) and stdout is not a TTY. Pass --yes to confirm.`, 1);
139
+ }
140
+ if (dryRun && !op.dryRunSupported && op.sideEffect === "write") {
141
+ trace(op, { exit_code: 1, error_code: "dry_run_unsupported" });
142
+ writeError("dry_run_unsupported", `Operation ${op.cliKey} does not support server-side dry-run (X-Dry-Run).`, 1);
143
+ }
144
+ // 5. build URL + query string
145
+ let url = creds.baseUrl + op.path;
146
+ // path templating: {id} → URL-encoded value, removed from query
147
+ const pathParamRegex = /\{([^}]+)\}/g;
148
+ url = url.replace(pathParamRegex, (_full, name) => {
149
+ const v = apiParams[name];
150
+ if (v === undefined) {
151
+ trace(op, { exit_code: 1, error_code: "invalid_args" });
152
+ writeError("invalid_args", `Path parameter '${name}' is required.`, 1);
153
+ }
154
+ delete apiParams[name];
155
+ return encodeURIComponent(String(v));
156
+ });
157
+ // 5a. WS streaming endpoint — REMOVED. CLI no longer ships /v1/ws/{news,views}
158
+ // as commands. The OpenAPI ops still exist for partner SDKs but the
159
+ // codegen drops x-cli-key, so this branch is unreachable today and
160
+ // should stay deleted unless the partner-side WS auth model is reworked
161
+ // (see WS removal commit + plan §3 follow-ups).
162
+ // 5b. paginate `--all`
163
+ if (args.all && op.pagination !== "none") {
164
+ try {
165
+ const result = await paginate(op, apiParams, {
166
+ baseUrl: creds.baseUrl,
167
+ bearer: creds.key,
168
+ cliVersion: ctx.cliVersion,
169
+ debug,
170
+ ...(typeof args.max_pages === "string"
171
+ ? { maxPages: Number(args.max_pages) }
172
+ : typeof args.max_pages === "number"
173
+ ? { maxPages: args.max_pages }
174
+ : {}),
175
+ ...(typeof args.max_items === "string"
176
+ ? { maxItems: Number(args.max_items) }
177
+ : typeof args.max_items === "number"
178
+ ? { maxItems: args.max_items }
179
+ : {}),
180
+ });
181
+ await writeStderr(`[paginate] ${result.pages} pages, ${result.items} items\n`);
182
+ trace(op, { exit_code: 0 });
183
+ process.exit(0);
184
+ }
185
+ catch (e) {
186
+ if (e instanceof CallApiError) {
187
+ const exitCode = exitCodeForPaginateError(e);
188
+ trace(op, {
189
+ request_id: e.requestId,
190
+ status: e.httpStatus,
191
+ exit_code: exitCode,
192
+ error_code: e.code,
193
+ });
194
+ writeError(e.code, e.message, exitCode, {
195
+ retryable: e.retryable,
196
+ ...(e.recovery_hint ? { recovery_hint: e.recovery_hint } : {}),
197
+ ...(e.requestId ? { request_id: e.requestId } : {}),
198
+ });
199
+ }
200
+ trace(op, { exit_code: 2, error_code: "internal_error" });
201
+ writeError("internal_error", e instanceof Error ? e.message : String(e), 2);
202
+ }
203
+ }
204
+ // 5c. single-shot HTTP request
205
+ const queryString = buildQueryString(apiParams);
206
+ if (op.method === "GET" && queryString)
207
+ url += "?" + queryString;
208
+ const { headers, requestId: clientRequestId } = buildHttpHeaders({
209
+ bearer: creds.key,
210
+ cliVersion: ctx.cliVersion,
211
+ });
212
+ // Idempotency-Key for endpoints that require it
213
+ if (op.idempotencyRequired) {
214
+ const userKey = typeof args.idempotency_key === "string" ? args.idempotency_key : null;
215
+ const idemKey = userKey || generateIdempotencyKey();
216
+ headers.set("Idempotency-Key", idemKey);
217
+ if (!userKey)
218
+ announceKey(idemKey, op.cliKey);
219
+ }
220
+ // X-Dry-Run: only set on writes where the server explicitly advertises
221
+ // support (op.dryRunSupported). Read ops with --dry-run flow through
222
+ // unchanged — they're a noop server-side, so there's nothing to dry-run.
223
+ if (dryRun && op.dryRunSupported) {
224
+ headers.set("X-Dry-Run", "1");
225
+ }
226
+ const init = {
227
+ method: op.method,
228
+ headers,
229
+ };
230
+ if (op.method === "POST") {
231
+ headers.set("Content-Type", "application/json");
232
+ init.body = JSON.stringify(apiParams);
233
+ }
234
+ if (debug) {
235
+ process.stderr.write(`> ${op.method} ${url}\n` +
236
+ `> Authorization: Bearer eps_live_***_***\n` +
237
+ Array.from(headers.entries())
238
+ .filter(([k]) => k.toLowerCase() !== "authorization")
239
+ .map(([k, v]) => `> ${k}: ${v}`)
240
+ .join("\n") +
241
+ "\n");
242
+ }
243
+ // 6. fire request
244
+ const startedAt = Date.now();
245
+ let res;
246
+ try {
247
+ res = await fetch(url, init);
248
+ }
249
+ catch (e) {
250
+ trace(op, { exit_code: 2, error_code: "network_error" });
251
+ writeError("network_error", e instanceof Error ? e.message : String(e), 2);
252
+ }
253
+ const requestId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
254
+ const apiVersion = res.headers.get("x-api-version") || undefined;
255
+ const ct = res.headers.get("content-type") || "";
256
+ const bodyText = await res.text();
257
+ let bodyJson = null;
258
+ if (ct.includes("application/json") && bodyText) {
259
+ try {
260
+ bodyJson = JSON.parse(bodyText);
261
+ }
262
+ catch {
263
+ // body 不是 JSON
264
+ }
265
+ }
266
+ const durationMs = Date.now() - startedAt;
267
+ if (debug) {
268
+ process.stderr.write(`< ${res.status} ${res.statusText}\n`);
269
+ if (requestId)
270
+ process.stderr.write(`< x-request-id: ${requestId}\n`);
271
+ }
272
+ // 7. error path
273
+ if (res.status >= 400) {
274
+ const apiErr = tryParseErrorEnvelope(bodyJson, res.status, requestId);
275
+ const exitCode = exitCodeForStatus(res.status);
276
+ const code = apiErr?.code ?? "http_error";
277
+ trace(op, {
278
+ request_id: requestId,
279
+ status: res.status,
280
+ duration_ms: durationMs,
281
+ exit_code: exitCode,
282
+ error_code: code,
283
+ });
284
+ if (apiErr) {
285
+ writeError(apiErr.code, apiErr.message, exitCode, {
286
+ retryable: apiErr.retryable,
287
+ ...(apiErr.recovery_hint ? { recovery_hint: apiErr.recovery_hint } : {}),
288
+ request_id: requestId,
289
+ });
290
+ }
291
+ writeError("http_error", `HTTP ${res.status} ${res.statusText}: ${bodyText.slice(0, 200)}`, exitCode, { request_id: requestId });
292
+ }
293
+ // 8. success path — render
294
+ if (raw) {
295
+ await writeStdout(bodyText + "\n");
296
+ process.exit(0);
297
+ }
298
+ const rawEnvelope = buildResponseEnvelope(bodyJson ?? bodyText, {
299
+ requestId,
300
+ endpoint: op.path,
301
+ method: op.method,
302
+ cliVersion: ctx.cliVersion,
303
+ durationMs,
304
+ ...(apiVersion ? { apiVersion } : {}),
305
+ });
306
+ let envelope = rawEnvelope;
307
+ try {
308
+ // Global JMESPath filter is `--jq` (renamed from --query in Phase 6
309
+ // smoke-fix to avoid clashing with subcommand `--query` body params,
310
+ // e.g. news.search). Do NOT fall back to args.query here — that's the
311
+ // subcommand body field, not a JMESPath expression.
312
+ const jq = typeof args.jq === "string" ? args.jq : undefined;
313
+ envelope = applyFilters(rawEnvelope, {
314
+ ...(jq ? { query: jq } : {}),
315
+ ...(parseFieldsFlag(args.fields) ? { fields: parseFieldsFlag(args.fields) } : {}),
316
+ ...(parseMaxBytesFlag(args.max_bytes) ? { maxBytes: parseMaxBytesFlag(args.max_bytes) } : {}),
317
+ });
318
+ }
319
+ catch (e) {
320
+ if (e instanceof FilterError) {
321
+ trace(op, {
322
+ request_id: requestId,
323
+ status: res.status,
324
+ duration_ms: durationMs,
325
+ exit_code: 1,
326
+ error_code: "invalid_args",
327
+ });
328
+ writeError("invalid_args", e.message, 1);
329
+ }
330
+ throw e;
331
+ }
332
+ await writeStdout(render(envelope, outputFmt, isTty) + "\n");
333
+ trace(op, {
334
+ request_id: requestId,
335
+ status: res.status,
336
+ duration_ms: durationMs,
337
+ exit_code: 0,
338
+ truncated: envelope.meta.truncated === true,
339
+ });
340
+ process.exit(0);
341
+ }
342
+ function buildQueryString(params) {
343
+ const parts = [];
344
+ for (const [k, v] of Object.entries(params)) {
345
+ if (v === undefined || v === null)
346
+ continue;
347
+ if (Array.isArray(v)) {
348
+ // edge-gateway expects ?codes=A,B not ?codes=A&codes=B
349
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
350
+ }
351
+ else {
352
+ parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
353
+ }
354
+ }
355
+ return parts.join("&");
356
+ }
357
+ function trace(op, t) {
358
+ appendTrace({
359
+ ts: new Date().toISOString(),
360
+ request_id: t.request_id ?? null,
361
+ cmd: op.cliKey,
362
+ status: t.status ?? null,
363
+ duration_ms: t.duration_ms ?? null,
364
+ exit_code: t.exit_code,
365
+ ...(t.truncated ? { truncated: true } : {}),
366
+ ...(t.error_code ? { error_code: t.error_code } : {}),
367
+ });
368
+ }
369
+ function writeError(code, message, exitCode, extras) {
370
+ const retryable = extras?.retryable ?? isRetryableByCode(code);
371
+ const hint = resolveRecoveryHint(code, extras?.recovery_hint);
372
+ const env = {
373
+ error: { code, message, retryable },
374
+ };
375
+ if (hint)
376
+ env.error.recovery_hint = hint;
377
+ if (extras?.request_id)
378
+ env.error.request_id = extras.request_id;
379
+ // TTY: 多行人性化输出 (color + recovery_hint 高亮);非 TTY 输出 single-line JSON。
380
+ if (isTtyHuman) {
381
+ process.stderr.write(renderError(env) + "\n");
382
+ }
383
+ else {
384
+ process.stderr.write(JSON.stringify(env) + "\n");
385
+ }
386
+ process.exit(exitCode);
387
+ }
@@ -0,0 +1,16 @@
1
+ function writeStream(stream, text) {
2
+ return new Promise((resolve, reject) => {
3
+ stream.write(text, (err) => {
4
+ if (err)
5
+ reject(err);
6
+ else
7
+ resolve();
8
+ });
9
+ });
10
+ }
11
+ export function writeStdout(text) {
12
+ return writeStream(process.stdout, text);
13
+ }
14
+ export function writeStderr(text) {
15
+ return writeStream(process.stderr, text);
16
+ }