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.
- package/README.md +386 -0
- package/dist/_generated/commands.js +274 -0
- package/dist/_generated/help.js +190 -0
- package/dist/_generated/operations.js +1306 -0
- package/dist/bin.js +170 -0
- package/dist/runtime/auth.js +95 -0
- package/dist/runtime/envelope.js +52 -0
- package/dist/runtime/errors.js +186 -0
- package/dist/runtime/filters.js +153 -0
- package/dist/runtime/format.js +143 -0
- package/dist/runtime/http.js +65 -0
- package/dist/runtime/idempotency.js +18 -0
- package/dist/runtime/invoker.js +387 -0
- package/dist/runtime/io.js +16 -0
- package/dist/runtime/paginator.js +146 -0
- package/dist/runtime/trace.js +99 -0
- package/dist/runtime/tty.js +51 -0
- package/dist/runtime/verb_cmd.js +70 -0
- package/dist/runtime/verb_runner.js +152 -0
- package/dist/runtime/whoami_cache.js +109 -0
- package/dist/tools/api.js +81 -0
- package/dist/tools/completion.js +116 -0
- package/dist/tools/config.js +123 -0
- package/dist/tools/doctor.js +183 -0
- package/dist/tools/login.js +99 -0
- package/dist/tools/mcp.js +141 -0
- package/dist/tools/raw.js +96 -0
- package/dist/tools/schema.js +58 -0
- package/dist/tools/trace.js +54 -0
- package/dist/tools/whoami.js +132 -0
- package/dist/verbs/_spec.js +15 -0
- package/dist/verbs/bars_batch.js +66 -0
- package/dist/verbs/chart.js +110 -0
- package/dist/verbs/digest.js +342 -0
- package/dist/verbs/hot.js +29 -0
- package/dist/verbs/index.js +49 -0
- package/dist/verbs/lookup.js +72 -0
- package/dist/verbs/news.js +67 -0
- package/dist/verbs/quote.js +53 -0
- package/dist/verbs/research.js +44 -0
- package/dist/verbs/scan.js +42 -0
- package/dist/verbs/sentiment.js +46 -0
- package/dist/verbs/views.js +83 -0
- package/dist/version.js +5 -0
- package/package.json +58 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pagination 策略:自动 follow next_cursor / next_offset 把全部 page NDJSON 输出。
|
|
3
|
+
*
|
|
4
|
+
* 触发:用户加 `--all` flag。`x-cli-pagination` 决定策略:
|
|
5
|
+
* - `cursor`:response.meta.next_cursor 非空 → 透传到下一次请求 ?cursor=...
|
|
6
|
+
* - `offset`:response.data.has_more=true → 用 ?offset = sum(已收) 拉下一页
|
|
7
|
+
* - `none`:不该走 pagination 路径
|
|
8
|
+
*
|
|
9
|
+
* 输出:NDJSON,每行一条 item(剥去 envelope,便于 `| jq` `| while read` 等管道)。
|
|
10
|
+
*
|
|
11
|
+
* 上限:默认 1_000 页或 100_000 items 防失控;可 --max-pages / --max-items 覆盖。
|
|
12
|
+
*/
|
|
13
|
+
import { fetch as undiciFetch } from "undici";
|
|
14
|
+
import { tryParseErrorEnvelope, exitCodeForStatus, CallApiError } from "./errors.js";
|
|
15
|
+
import { buildHttpHeaders, resolveRequestId } from "./http.js";
|
|
16
|
+
import { writeStdout } from "./io.js";
|
|
17
|
+
const DEFAULT_MAX_PAGES = 1000;
|
|
18
|
+
const DEFAULT_MAX_ITEMS = 100_000;
|
|
19
|
+
/**
|
|
20
|
+
* 调用并 NDJSON-stream 所有 page。失败抛 CallApiError。
|
|
21
|
+
*
|
|
22
|
+
* write 由调用方注入(默认走 process.stdout.write 且等待 flush,便于测试 mock)。
|
|
23
|
+
*/
|
|
24
|
+
export async function paginate(op, initialParams, ctx, write = writeStdout) {
|
|
25
|
+
if (op.pagination === "none") {
|
|
26
|
+
throw new Error(`${op.cliKey}: --all not supported (x-cli-pagination=none)`);
|
|
27
|
+
}
|
|
28
|
+
const maxPages = ctx.maxPages ?? DEFAULT_MAX_PAGES;
|
|
29
|
+
const maxItems = ctx.maxItems ?? DEFAULT_MAX_ITEMS;
|
|
30
|
+
const fetchFn = ctx.fetchImpl ?? undiciFetch;
|
|
31
|
+
const params = { ...initialParams };
|
|
32
|
+
let pages = 0;
|
|
33
|
+
let items = 0;
|
|
34
|
+
while (true) {
|
|
35
|
+
const url = ctx.baseUrl + op.path + "?" + buildQueryString(params);
|
|
36
|
+
// Rebuild headers per page — each page is a distinct request and needs
|
|
37
|
+
// its own X-Request-Id for server-side correlation.
|
|
38
|
+
const { headers, requestId: clientRequestId } = buildHttpHeaders({
|
|
39
|
+
bearer: ctx.bearer,
|
|
40
|
+
cliVersion: ctx.cliVersion,
|
|
41
|
+
});
|
|
42
|
+
if (ctx.debug) {
|
|
43
|
+
process.stderr.write(`> [page ${pages + 1}] ${op.method} ${url}\n`);
|
|
44
|
+
}
|
|
45
|
+
const res = await fetchFn(url, { method: op.method, headers });
|
|
46
|
+
const body = await res.text();
|
|
47
|
+
let json = null;
|
|
48
|
+
try {
|
|
49
|
+
json = body ? JSON.parse(body) : null;
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// ignore
|
|
53
|
+
}
|
|
54
|
+
if (res.status >= 400) {
|
|
55
|
+
const reqId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
|
|
56
|
+
const apiErr = tryParseErrorEnvelope(json, res.status, reqId);
|
|
57
|
+
throw apiErr ??
|
|
58
|
+
new CallApiError({
|
|
59
|
+
code: "http_error",
|
|
60
|
+
message: `HTTP ${res.status}`,
|
|
61
|
+
httpStatus: res.status,
|
|
62
|
+
requestId: reqId,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
pages += 1;
|
|
66
|
+
const { itemsArr, nextParams, hasMore } = extractPage(op, json, params);
|
|
67
|
+
for (const item of itemsArr) {
|
|
68
|
+
await write(JSON.stringify(item) + "\n");
|
|
69
|
+
items += 1;
|
|
70
|
+
if (items >= maxItems) {
|
|
71
|
+
if (ctx.debug)
|
|
72
|
+
process.stderr.write(`[paginate] hit max-items ${maxItems}, stopping\n`);
|
|
73
|
+
return { pages, items };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!hasMore)
|
|
77
|
+
return { pages, items };
|
|
78
|
+
if (pages >= maxPages) {
|
|
79
|
+
if (ctx.debug)
|
|
80
|
+
process.stderr.write(`[paginate] hit max-pages ${maxPages}, stopping\n`);
|
|
81
|
+
return { pages, items };
|
|
82
|
+
}
|
|
83
|
+
Object.assign(params, nextParams);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
function extractPage(op, body, currentParams) {
|
|
87
|
+
// 公认 envelope 形态:
|
|
88
|
+
// { data: { items: [...], total?, has_more?, next_cursor? }, meta?: { next_cursor? } }
|
|
89
|
+
// 或 { data: [...], meta: { next_cursor? } } —— 直接 array
|
|
90
|
+
let itemsArr = [];
|
|
91
|
+
let dataObj = null;
|
|
92
|
+
let metaObj = null;
|
|
93
|
+
if (typeof body === "object" && body !== null) {
|
|
94
|
+
const top = body;
|
|
95
|
+
if (Array.isArray(top.data)) {
|
|
96
|
+
itemsArr = top.data;
|
|
97
|
+
}
|
|
98
|
+
else if (typeof top.data === "object" && top.data !== null) {
|
|
99
|
+
dataObj = top.data;
|
|
100
|
+
if (Array.isArray(dataObj.items))
|
|
101
|
+
itemsArr = dataObj.items;
|
|
102
|
+
}
|
|
103
|
+
else if (Array.isArray(top.items)) {
|
|
104
|
+
dataObj = top;
|
|
105
|
+
itemsArr = top.items;
|
|
106
|
+
}
|
|
107
|
+
if (typeof top.meta === "object" && top.meta !== null) {
|
|
108
|
+
metaObj = top.meta;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (op.pagination === "cursor") {
|
|
112
|
+
const nextCursor = metaObj?.next_cursor ??
|
|
113
|
+
dataObj?.next_cursor;
|
|
114
|
+
if (typeof nextCursor === "string" && nextCursor) {
|
|
115
|
+
return { itemsArr, nextParams: { cursor: nextCursor }, hasMore: true };
|
|
116
|
+
}
|
|
117
|
+
return { itemsArr, nextParams: {}, hasMore: false };
|
|
118
|
+
}
|
|
119
|
+
if (op.pagination === "offset") {
|
|
120
|
+
const hasMore = Boolean(dataObj?.has_more);
|
|
121
|
+
if (!hasMore)
|
|
122
|
+
return { itemsArr, nextParams: {}, hasMore: false };
|
|
123
|
+
const currentOffset = Number(currentParams.offset ?? 0) + itemsArr.length;
|
|
124
|
+
return { itemsArr, nextParams: { offset: currentOffset }, hasMore: true };
|
|
125
|
+
}
|
|
126
|
+
return { itemsArr, nextParams: {}, hasMore: false };
|
|
127
|
+
}
|
|
128
|
+
function buildQueryString(params) {
|
|
129
|
+
const parts = [];
|
|
130
|
+
for (const [k, v] of Object.entries(params)) {
|
|
131
|
+
if (v === undefined || v === null)
|
|
132
|
+
continue;
|
|
133
|
+
if (Array.isArray(v)) {
|
|
134
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return parts.join("&");
|
|
141
|
+
}
|
|
142
|
+
export function exitCodeForPaginateError(e) {
|
|
143
|
+
if (e instanceof CallApiError && e.httpStatus)
|
|
144
|
+
return exitCodeForStatus(e.httpStatus);
|
|
145
|
+
return 2;
|
|
146
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local CLI invocation trace (ring buffer).
|
|
3
|
+
*
|
|
4
|
+
* 写入 `~/.echopai/trace.ndjson`,每行一条 NDJSON。文件达 RING_MAX_BYTES (50MB)
|
|
5
|
+
* 时旋转为 `trace.ndjson.1` (覆盖前一代)。
|
|
6
|
+
*
|
|
7
|
+
* 用途:事后追溯 request_id / 看最近一次 401 是什么命令打的 / 排查 agent
|
|
8
|
+
* 调用频率。完全本地、不外发、对 prod 零影响。
|
|
9
|
+
*
|
|
10
|
+
* 关闭:`ECHOPAI_NO_TRACE=1`。
|
|
11
|
+
* 重定向:`ECHOPAI_TRACE_FILE=/path/to/file` (主要给测试用)。
|
|
12
|
+
*
|
|
13
|
+
* 失败容错:trace 是 best-effort,任何 fs 错误吞掉,绝不让用户命令因 trace
|
|
14
|
+
* 失败而 crash。
|
|
15
|
+
*
|
|
16
|
+
* 同步写入:CLI 退出时序 (process.exit) 不等 async flush,所以全用 sync API。
|
|
17
|
+
* 一次 appendFileSync ≈ μs 级,对 CLI 启停 latency 可忽略。
|
|
18
|
+
*/
|
|
19
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync, } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { dirname, join } from "node:path";
|
|
22
|
+
const DEFAULT_TRACE_FILE = join(homedir(), ".echopai", "trace.ndjson");
|
|
23
|
+
const RING_MAX_BYTES = 50 * 1024 * 1024;
|
|
24
|
+
export function isTraceDisabled() {
|
|
25
|
+
return process.env.ECHOPAI_NO_TRACE === "1";
|
|
26
|
+
}
|
|
27
|
+
export function resolveTraceFile(opts) {
|
|
28
|
+
return (opts?.filePath ?? process.env.ECHOPAI_TRACE_FILE ?? DEFAULT_TRACE_FILE);
|
|
29
|
+
}
|
|
30
|
+
export function appendTrace(record, opts) {
|
|
31
|
+
if (isTraceDisabled())
|
|
32
|
+
return;
|
|
33
|
+
const file = resolveTraceFile(opts);
|
|
34
|
+
const maxBytes = opts?.ringMaxBytes ?? RING_MAX_BYTES;
|
|
35
|
+
try {
|
|
36
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
37
|
+
if (existsSync(file)) {
|
|
38
|
+
const size = statSync(file).size;
|
|
39
|
+
if (size >= maxBytes) {
|
|
40
|
+
renameSync(file, file + ".1");
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
appendFileSync(file, JSON.stringify(record) + "\n");
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// best-effort
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function readNdjsonLines(file) {
|
|
50
|
+
try {
|
|
51
|
+
if (!existsSync(file))
|
|
52
|
+
return [];
|
|
53
|
+
const raw = readFileSync(file, "utf-8");
|
|
54
|
+
const out = [];
|
|
55
|
+
for (const line of raw.split("\n")) {
|
|
56
|
+
if (line.length === 0)
|
|
57
|
+
continue;
|
|
58
|
+
try {
|
|
59
|
+
out.push(JSON.parse(line));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// skip malformed line
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Last N records in chronological order (oldest → newest).
|
|
73
|
+
* Reads current ring file; falls back to .1 to fill if needed.
|
|
74
|
+
*/
|
|
75
|
+
export function tailTraces(n, opts) {
|
|
76
|
+
if (n <= 0)
|
|
77
|
+
return [];
|
|
78
|
+
const file = resolveTraceFile(opts);
|
|
79
|
+
const current = readNdjsonLines(file);
|
|
80
|
+
if (current.length >= n)
|
|
81
|
+
return current.slice(-n);
|
|
82
|
+
const previous = readNdjsonLines(file + ".1");
|
|
83
|
+
return previous.concat(current).slice(-n);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Find a single record by request_id. Scans current ring then .1; newest first
|
|
87
|
+
* (so retried request_ids return the latest occurrence).
|
|
88
|
+
*/
|
|
89
|
+
export function getTraceByRequestId(requestId, opts) {
|
|
90
|
+
const file = resolveTraceFile(opts);
|
|
91
|
+
for (const candidate of [file, file + ".1"]) {
|
|
92
|
+
const records = readNdjsonLines(candidate);
|
|
93
|
+
for (let i = records.length - 1; i >= 0; i--) {
|
|
94
|
+
if (records[i].request_id === requestId)
|
|
95
|
+
return records[i];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TTY 检测 + 极小 ANSI 着色(无依赖,避免 chalk/ora 拖慢 CLI 启动)。
|
|
3
|
+
*
|
|
4
|
+
* 启用规则:
|
|
5
|
+
* - process.stdout.isTTY === true
|
|
6
|
+
* - !process.env.CI
|
|
7
|
+
* - !process.env.NO_COLOR
|
|
8
|
+
* - process.env.TERM !== 'dumb'
|
|
9
|
+
*
|
|
10
|
+
* 任一失败 → color() / dim() / errSym() 返回原文(脚本/AI 场景纯洁)。
|
|
11
|
+
*/
|
|
12
|
+
const enable = (() => {
|
|
13
|
+
if (!process.stderr.isTTY)
|
|
14
|
+
return false;
|
|
15
|
+
if (process.env.CI)
|
|
16
|
+
return false;
|
|
17
|
+
if (process.env.NO_COLOR)
|
|
18
|
+
return false;
|
|
19
|
+
if (process.env.TERM === "dumb")
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
})();
|
|
23
|
+
export const isTtyHuman = enable;
|
|
24
|
+
const ESC = "[";
|
|
25
|
+
function ansi(code, s) {
|
|
26
|
+
if (!enable)
|
|
27
|
+
return s;
|
|
28
|
+
return `${ESC}${code}m${s}${ESC}0m`;
|
|
29
|
+
}
|
|
30
|
+
export const red = (s) => ansi("31", s);
|
|
31
|
+
export const green = (s) => ansi("32", s);
|
|
32
|
+
export const yellow = (s) => ansi("33", s);
|
|
33
|
+
export const cyan = (s) => ansi("36", s);
|
|
34
|
+
export const dim = (s) => ansi("2", s);
|
|
35
|
+
export const bold = (s) => ansi("1", s);
|
|
36
|
+
/**
|
|
37
|
+
* 把 error envelope 渲染成多行人性化文字(仅 TTY)。
|
|
38
|
+
* 非 TTY 直接 JSON.stringify。
|
|
39
|
+
*/
|
|
40
|
+
export function renderError(env) {
|
|
41
|
+
if (!enable)
|
|
42
|
+
return JSON.stringify(env);
|
|
43
|
+
const e = env.error;
|
|
44
|
+
const label = `${red(bold("✗ " + e.code))}${e.retryable ? " " + dim("(retryable)") : ""}`;
|
|
45
|
+
const lines = [`${label} ${dim("—")} ${e.message}`];
|
|
46
|
+
if (e.recovery_hint)
|
|
47
|
+
lines.push(` ${cyan("hint:")} ${e.recovery_hint}`);
|
|
48
|
+
if (e.request_id)
|
|
49
|
+
lines.push(` ${dim("request_id: " + e.request_id)}`);
|
|
50
|
+
return lines.join("\n");
|
|
51
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scaffolding for curated agent verbs.
|
|
3
|
+
*
|
|
4
|
+
* 每个 verb 都做同样三件事:解析 credential → 调一次或多次 callOp → emit
|
|
5
|
+
* envelope + exit。本模块把样板抽出,让 verb 实现只关心"参数 → 派生 args → 映射
|
|
6
|
+
* 输出"。
|
|
7
|
+
*
|
|
8
|
+
* 与 verb_runner.ts 的区别:
|
|
9
|
+
* - verb_runner: 纯 HTTP/Ajv 层,不知道 commander / exit / stderr renderer
|
|
10
|
+
* - verb_cmd: process / commander 层 ergonomics,把 runner 包成"一行起 verb"
|
|
11
|
+
*/
|
|
12
|
+
import { resolveCredentials, AuthMissingError } from "./auth.js";
|
|
13
|
+
import { CallApiError } from "./errors.js";
|
|
14
|
+
import { isTtyHuman, renderError } from "./tty.js";
|
|
15
|
+
import { CLI_VERSION } from "../version.js";
|
|
16
|
+
/**
|
|
17
|
+
* Run a verb handler end-to-end:
|
|
18
|
+
* 1. Resolve credentials (auth_missing → exit 1)
|
|
19
|
+
* 2. Invoke handler with ctx
|
|
20
|
+
* 3. Print stdout envelope + exit 0 on success
|
|
21
|
+
* 4. Map CallApiError → structured stderr + exit 1/2 by HTTP class
|
|
22
|
+
*/
|
|
23
|
+
export async function executeVerb(handler) {
|
|
24
|
+
let creds;
|
|
25
|
+
try {
|
|
26
|
+
creds = resolveCredentials({});
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
if (e instanceof AuthMissingError) {
|
|
30
|
+
emitVerbError("auth_missing", e.message, e.recovery_hint, 1);
|
|
31
|
+
}
|
|
32
|
+
throw e;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const env = await handler({
|
|
36
|
+
baseUrl: creds.baseUrl,
|
|
37
|
+
bearer: creds.key,
|
|
38
|
+
cliVersion: CLI_VERSION,
|
|
39
|
+
});
|
|
40
|
+
process.stdout.write(JSON.stringify(env) + "\n");
|
|
41
|
+
process.exit(0);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
if (e instanceof CallApiError) {
|
|
45
|
+
emitVerbError(e.code, e.message, e.recovery_hint, e.httpStatus && e.httpStatus < 500 ? 1 : 2, e.requestId);
|
|
46
|
+
}
|
|
47
|
+
// unexpected error: structured emit, exit 2
|
|
48
|
+
emitVerbError("internal_error", e instanceof Error ? e.message : String(e), undefined, 2);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function emitVerbError(code, message, recoveryHint, exitCode, requestId) {
|
|
52
|
+
const env = {
|
|
53
|
+
error: {
|
|
54
|
+
code,
|
|
55
|
+
message,
|
|
56
|
+
retryable: false,
|
|
57
|
+
...(recoveryHint ? { recovery_hint: recoveryHint } : {}),
|
|
58
|
+
...(requestId ? { request_id: requestId } : {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
if (isTtyHuman) {
|
|
62
|
+
process.stderr.write(renderError(env) + "\n");
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
process.stderr.write(JSON.stringify(env) + "\n");
|
|
66
|
+
}
|
|
67
|
+
process.exit(exitCode);
|
|
68
|
+
}
|
|
69
|
+
/** Re-export for verb-local convenience. */
|
|
70
|
+
export { CLI_VERSION };
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic operation runner — shared core for curated agent verbs.
|
|
3
|
+
*
|
|
4
|
+
* 与 invoker.ts 的关系:invoker.ts 是 commander 入口 (强耦合 process.exit / 错误
|
|
5
|
+
* stderr 输出 / trace 记录);本模块只做"调一次操作 → 返回 envelope",便于 verb
|
|
6
|
+
* 实现里组合多次调用 (e.g. digest fan-out) 或在调完后映射 output。
|
|
7
|
+
*
|
|
8
|
+
* 不做的事:
|
|
9
|
+
* - --all 分页 (verb 自己决定要不要)
|
|
10
|
+
* - WebSocket streaming (单帧场景不存在)
|
|
11
|
+
* - Idempotency-Key 自动注入 (Phase 6 写 verb 显式控制)
|
|
12
|
+
* - trace 写入 (调用方自己 trace)
|
|
13
|
+
*
|
|
14
|
+
* 错误:抛 CallApiError;调用方负责 catch + 决定 exit / 部分失败容错。
|
|
15
|
+
*/
|
|
16
|
+
import AjvPkg from "ajv";
|
|
17
|
+
import addFormatsPkg from "ajv-formats";
|
|
18
|
+
import { fetch as undiciFetch } from "undici";
|
|
19
|
+
import { buildResponseEnvelope } from "./envelope.js";
|
|
20
|
+
import { CallApiError, tryParseErrorEnvelope } from "./errors.js";
|
|
21
|
+
import { buildHttpHeaders, resolveRequestId } from "./http.js";
|
|
22
|
+
const Ajv = (AjvPkg.default ??
|
|
23
|
+
AjvPkg);
|
|
24
|
+
const addFormats = (addFormatsPkg.default ??
|
|
25
|
+
addFormatsPkg);
|
|
26
|
+
const ajv = new Ajv({
|
|
27
|
+
allErrors: true,
|
|
28
|
+
useDefaults: true,
|
|
29
|
+
coerceTypes: "array",
|
|
30
|
+
strict: false,
|
|
31
|
+
});
|
|
32
|
+
addFormats(ajv);
|
|
33
|
+
/**
|
|
34
|
+
* Invoke a single OpenAPI operation programmatically. Returns the standard
|
|
35
|
+
* response envelope; throws CallApiError on failure (network / HTTP 4xx-5xx /
|
|
36
|
+
* malformed response).
|
|
37
|
+
*
|
|
38
|
+
* Validation:
|
|
39
|
+
* - Pre-flight Ajv validates `args` against `op.inputSchema`. On validation
|
|
40
|
+
* failure, throws CallApiError(code="invalid_args").
|
|
41
|
+
*
|
|
42
|
+
* Path templating: occurrences of `{name}` in `op.path` are substituted from
|
|
43
|
+
* `args[name]` and removed from query.
|
|
44
|
+
*/
|
|
45
|
+
export async function callOp(op, args, ctx) {
|
|
46
|
+
const params = { ...args };
|
|
47
|
+
// Pre-flight schema validation
|
|
48
|
+
const validate = ajv.compile(op.inputSchema);
|
|
49
|
+
if (!validate(params)) {
|
|
50
|
+
const errs = (validate.errors || [])
|
|
51
|
+
.map((e) => `${e.instancePath || "(root)"}: ${e.message ?? "invalid"}`)
|
|
52
|
+
.join("; ");
|
|
53
|
+
throw new CallApiError({
|
|
54
|
+
code: "invalid_args",
|
|
55
|
+
message: `Input validation failed: ${errs}`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
// Path templating
|
|
59
|
+
let url = ctx.baseUrl.replace(/\/+$/, "") + op.path;
|
|
60
|
+
const pathParamRegex = /\{([^}]+)\}/g;
|
|
61
|
+
const missingPathParam = [];
|
|
62
|
+
url = url.replace(pathParamRegex, (_full, name) => {
|
|
63
|
+
const v = params[name];
|
|
64
|
+
if (v === undefined) {
|
|
65
|
+
missingPathParam.push(name);
|
|
66
|
+
return _full;
|
|
67
|
+
}
|
|
68
|
+
delete params[name];
|
|
69
|
+
return encodeURIComponent(String(v));
|
|
70
|
+
});
|
|
71
|
+
if (missingPathParam.length > 0) {
|
|
72
|
+
throw new CallApiError({
|
|
73
|
+
code: "invalid_args",
|
|
74
|
+
message: `Path parameter(s) required: ${missingPathParam.join(", ")}`,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
// Query / body
|
|
78
|
+
const qs = buildQueryString(params, op.method);
|
|
79
|
+
if (op.method === "GET" && qs)
|
|
80
|
+
url += "?" + qs;
|
|
81
|
+
const { headers, requestId: clientRequestId } = buildHttpHeaders({
|
|
82
|
+
bearer: ctx.bearer,
|
|
83
|
+
cliVersion: ctx.cliVersion,
|
|
84
|
+
...(ctx.channel ? { channel: ctx.channel } : {}),
|
|
85
|
+
});
|
|
86
|
+
const init = { method: op.method, headers };
|
|
87
|
+
if (op.method === "POST") {
|
|
88
|
+
headers.set("Content-Type", "application/json");
|
|
89
|
+
init.body = JSON.stringify(params);
|
|
90
|
+
}
|
|
91
|
+
const fetchFn = ctx.fetchImpl ?? undiciFetch;
|
|
92
|
+
const startedAt = Date.now();
|
|
93
|
+
let res;
|
|
94
|
+
try {
|
|
95
|
+
res = await fetchFn(url, init);
|
|
96
|
+
}
|
|
97
|
+
catch (e) {
|
|
98
|
+
throw new CallApiError({
|
|
99
|
+
code: "network_error",
|
|
100
|
+
message: e instanceof Error ? e.message : String(e),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const requestId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
|
|
104
|
+
const apiVersion = res.headers.get("x-api-version") || undefined;
|
|
105
|
+
const ct = res.headers.get("content-type") || "";
|
|
106
|
+
const bodyText = await res.text();
|
|
107
|
+
let bodyJson = null;
|
|
108
|
+
if (ct.includes("application/json") && bodyText) {
|
|
109
|
+
try {
|
|
110
|
+
bodyJson = JSON.parse(bodyText);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// non-JSON body — let error path below handle
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const durationMs = Date.now() - startedAt;
|
|
117
|
+
if (res.status >= 400) {
|
|
118
|
+
const apiErr = tryParseErrorEnvelope(bodyJson, res.status, requestId);
|
|
119
|
+
if (apiErr)
|
|
120
|
+
throw apiErr;
|
|
121
|
+
throw new CallApiError({
|
|
122
|
+
code: "http_error",
|
|
123
|
+
message: `HTTP ${res.status} ${res.statusText}: ${bodyText.slice(0, 200)}`,
|
|
124
|
+
httpStatus: res.status,
|
|
125
|
+
requestId,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return buildResponseEnvelope(bodyJson ?? bodyText, {
|
|
129
|
+
requestId,
|
|
130
|
+
endpoint: op.path,
|
|
131
|
+
method: op.method,
|
|
132
|
+
cliVersion: ctx.cliVersion,
|
|
133
|
+
durationMs,
|
|
134
|
+
...(apiVersion ? { apiVersion } : {}),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
function buildQueryString(params, method) {
|
|
138
|
+
if (method !== "GET")
|
|
139
|
+
return "";
|
|
140
|
+
const parts = [];
|
|
141
|
+
for (const [k, v] of Object.entries(params)) {
|
|
142
|
+
if (v === undefined || v === null || v === "")
|
|
143
|
+
continue;
|
|
144
|
+
if (Array.isArray(v)) {
|
|
145
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(v.join(","))}`);
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
parts.push(`${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return parts.join("&");
|
|
152
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /v1/auth/whoami client + 5-minute in-process cache.
|
|
3
|
+
*
|
|
4
|
+
* 设计原则:
|
|
5
|
+
* - **单飞 (single-flight)**: 并发调用时只发一次真请求;多调用者共享 in-flight
|
|
6
|
+
* Promise。MCP serve 长 session 多并发 list-tools 场景下避免请求风暴。
|
|
7
|
+
* - **TTL 5 分钟**: cache hit 后跨命令复用 (process 退出即失效)。shell one-shot
|
|
8
|
+
* 每次多一次 whoami 调用;MCP server 长进程几乎永远 cache hit。
|
|
9
|
+
* - **bearer 切换不复用**: cache key 含 bearer prefix,换 token 自动 miss。
|
|
10
|
+
* - **best-effort 失败传播**: 网络 / auth 错误抛 CallApiError 上去由 caller
|
|
11
|
+
* 决定 (doctor 显示 fail,whoami exit 2)。
|
|
12
|
+
*
|
|
13
|
+
* 不在这里做 verb 派生 / unavailable 列表计算——那是 tools/whoami.ts 的职责。
|
|
14
|
+
*/
|
|
15
|
+
import { fetch as undiciFetch } from "undici";
|
|
16
|
+
import { CallApiError, tryParseErrorEnvelope } from "./errors.js";
|
|
17
|
+
import { buildHttpHeaders, resolveRequestId } from "./http.js";
|
|
18
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
19
|
+
let cache = null;
|
|
20
|
+
let inflight = null;
|
|
21
|
+
function cacheKey(ctx) {
|
|
22
|
+
// bearer 前 8 char + baseUrl 足以区分;不存全 bearer 避免日志 / dump 泄露。
|
|
23
|
+
return `${ctx.baseUrl}|${ctx.bearer.slice(0, 8)}|${ctx.channel ?? "cli"}`;
|
|
24
|
+
}
|
|
25
|
+
/** Test-only hook: reset module state between specs. */
|
|
26
|
+
export function clearWhoamiCache() {
|
|
27
|
+
cache = null;
|
|
28
|
+
inflight = null;
|
|
29
|
+
}
|
|
30
|
+
/** Public read of cache without triggering fetch (returns null if miss/expired). */
|
|
31
|
+
export function peekWhoamiCache(ctx) {
|
|
32
|
+
const k = cacheKey(ctx);
|
|
33
|
+
if (cache && cache.key === k && cache.expiresAt > Date.now())
|
|
34
|
+
return cache.resp;
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
export async function getWhoami(ctx, opts) {
|
|
38
|
+
const key = cacheKey(ctx);
|
|
39
|
+
const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
|
40
|
+
if (!opts?.force && cache && cache.key === key && cache.expiresAt > Date.now()) {
|
|
41
|
+
return cache.resp;
|
|
42
|
+
}
|
|
43
|
+
if (inflight && inflight.key === key) {
|
|
44
|
+
return inflight.promise;
|
|
45
|
+
}
|
|
46
|
+
const promise = doFetch(ctx, opts?.fetchImpl)
|
|
47
|
+
.then((resp) => {
|
|
48
|
+
cache = { key, resp, expiresAt: Date.now() + ttlMs };
|
|
49
|
+
return resp;
|
|
50
|
+
})
|
|
51
|
+
.finally(() => {
|
|
52
|
+
if (inflight && inflight.key === key)
|
|
53
|
+
inflight = null;
|
|
54
|
+
});
|
|
55
|
+
inflight = { key, promise };
|
|
56
|
+
return promise;
|
|
57
|
+
}
|
|
58
|
+
async function doFetch(ctx, fetchImpl) {
|
|
59
|
+
const fn = fetchImpl ?? undiciFetch;
|
|
60
|
+
const url = ctx.baseUrl.replace(/\/+$/, "") + "/v1/auth/whoami";
|
|
61
|
+
const { headers, requestId: clientRequestId } = buildHttpHeaders({
|
|
62
|
+
bearer: ctx.bearer,
|
|
63
|
+
cliVersion: ctx.cliVersion,
|
|
64
|
+
...(ctx.channel ? { channel: ctx.channel } : {}),
|
|
65
|
+
});
|
|
66
|
+
let res;
|
|
67
|
+
try {
|
|
68
|
+
res = await fn(url, { method: "GET", headers });
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
throw new CallApiError({
|
|
72
|
+
code: "network_error",
|
|
73
|
+
message: e instanceof Error ? e.message : String(e),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const requestId = resolveRequestId(res.headers.get("x-request-id"), clientRequestId);
|
|
77
|
+
const text = await res.text();
|
|
78
|
+
let json = null;
|
|
79
|
+
if (text) {
|
|
80
|
+
try {
|
|
81
|
+
json = JSON.parse(text);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// body not JSON — error path below will surface http_error
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (res.status >= 400) {
|
|
88
|
+
const env = tryParseErrorEnvelope(json, res.status, requestId);
|
|
89
|
+
if (env)
|
|
90
|
+
throw env;
|
|
91
|
+
throw new CallApiError({
|
|
92
|
+
code: "http_error",
|
|
93
|
+
message: `HTTP ${res.status} ${res.statusText}: ${text.slice(0, 200)}`,
|
|
94
|
+
httpStatus: res.status,
|
|
95
|
+
requestId,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
if (typeof json !== "object" ||
|
|
99
|
+
json === null ||
|
|
100
|
+
!("data" in json)) {
|
|
101
|
+
throw new CallApiError({
|
|
102
|
+
code: "internal_error",
|
|
103
|
+
message: "whoami: response missing `data` envelope",
|
|
104
|
+
httpStatus: res.status,
|
|
105
|
+
requestId,
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
return json.data;
|
|
109
|
+
}
|