echopai 2.3.0 → 2.4.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 +63 -348
- package/dist/bin.js +8298 -190
- package/package.json +11 -13
- package/dist/_generated/commands.js +0 -378
- package/dist/_generated/help.js +0 -295
- package/dist/_generated/operations.js +0 -2385
- package/dist/runtime/auth.js +0 -95
- package/dist/runtime/envelope.js +0 -52
- package/dist/runtime/errors.js +0 -186
- package/dist/runtime/filters.js +0 -153
- package/dist/runtime/format.js +0 -143
- package/dist/runtime/http.js +0 -65
- package/dist/runtime/idempotency.js +0 -18
- package/dist/runtime/invoker.js +0 -391
- package/dist/runtime/io.js +0 -16
- package/dist/runtime/paginator.js +0 -146
- package/dist/runtime/trace.js +0 -99
- package/dist/runtime/tty.js +0 -51
- package/dist/runtime/update_check.js +0 -120
- package/dist/runtime/update_worker.js +0 -63
- package/dist/runtime/verb_cmd.js +0 -72
- package/dist/runtime/verb_runner.js +0 -152
- package/dist/runtime/whoami_cache.js +0 -109
- package/dist/tools/api.js +0 -81
- package/dist/tools/completion.js +0 -116
- package/dist/tools/config.js +0 -123
- package/dist/tools/doctor.js +0 -183
- package/dist/tools/login.js +0 -99
- package/dist/tools/mcp.js +0 -141
- package/dist/tools/raw.js +0 -96
- package/dist/tools/schema.js +0 -58
- package/dist/tools/trace.js +0 -54
- package/dist/tools/upgrade.js +0 -103
- package/dist/tools/welcome.js +0 -225
- package/dist/tools/whoami.js +0 -132
- package/dist/verbs/_spec.js +0 -15
- package/dist/verbs/announcements.js +0 -195
- package/dist/verbs/bars_batch.js +0 -66
- package/dist/verbs/chart.js +0 -110
- package/dist/verbs/concepts.js +0 -393
- package/dist/verbs/digest.js +0 -351
- package/dist/verbs/financials.js +0 -212
- package/dist/verbs/hot.js +0 -29
- package/dist/verbs/index.js +0 -88
- package/dist/verbs/limit_up.js +0 -156
- package/dist/verbs/lookup.js +0 -72
- package/dist/verbs/market.js +0 -185
- package/dist/verbs/news.js +0 -81
- package/dist/verbs/quote.js +0 -53
- package/dist/verbs/scan.js +0 -42
- package/dist/verbs/search.js +0 -105
- package/dist/verbs/sentiment.js +0 -231
- package/dist/verbs/views.js +0 -85
- package/dist/version.js +0 -5
package/dist/runtime/tty.js
DELETED
|
@@ -1,51 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,120 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,63 +0,0 @@
|
|
|
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(() => { });
|
package/dist/runtime/verb_cmd.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
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 { maybeEmitUpdateBanner } from "./update_check.js";
|
|
16
|
-
import { CLI_VERSION } from "../version.js";
|
|
17
|
-
/**
|
|
18
|
-
* Run a verb handler end-to-end:
|
|
19
|
-
* 1. Resolve credentials (auth_missing → exit 1)
|
|
20
|
-
* 2. Invoke handler with ctx
|
|
21
|
-
* 3. Print stdout envelope + exit 0 on success
|
|
22
|
-
* 4. Map CallApiError → structured stderr + exit 1/2 by HTTP class
|
|
23
|
-
*/
|
|
24
|
-
export async function executeVerb(handler) {
|
|
25
|
-
let creds;
|
|
26
|
-
try {
|
|
27
|
-
creds = resolveCredentials({});
|
|
28
|
-
}
|
|
29
|
-
catch (e) {
|
|
30
|
-
if (e instanceof AuthMissingError) {
|
|
31
|
-
emitVerbError("auth_missing", e.message, e.recovery_hint, 1);
|
|
32
|
-
}
|
|
33
|
-
throw e;
|
|
34
|
-
}
|
|
35
|
-
try {
|
|
36
|
-
const env = await handler({
|
|
37
|
-
baseUrl: creds.baseUrl,
|
|
38
|
-
bearer: creds.key,
|
|
39
|
-
cliVersion: CLI_VERSION,
|
|
40
|
-
});
|
|
41
|
-
process.stdout.write(JSON.stringify(env) + "\n");
|
|
42
|
-
maybeEmitUpdateBanner();
|
|
43
|
-
process.exit(0);
|
|
44
|
-
}
|
|
45
|
-
catch (e) {
|
|
46
|
-
if (e instanceof CallApiError) {
|
|
47
|
-
emitVerbError(e.code, e.message, e.recovery_hint, e.httpStatus && e.httpStatus < 500 ? 1 : 2, e.requestId);
|
|
48
|
-
}
|
|
49
|
-
// unexpected error: structured emit, exit 2
|
|
50
|
-
emitVerbError("internal_error", e instanceof Error ? e.message : String(e), undefined, 2);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
export function emitVerbError(code, message, recoveryHint, exitCode, requestId) {
|
|
54
|
-
const env = {
|
|
55
|
-
error: {
|
|
56
|
-
code,
|
|
57
|
-
message,
|
|
58
|
-
retryable: false,
|
|
59
|
-
...(recoveryHint ? { recovery_hint: recoveryHint } : {}),
|
|
60
|
-
...(requestId ? { request_id: requestId } : {}),
|
|
61
|
-
},
|
|
62
|
-
};
|
|
63
|
-
if (isTtyHuman) {
|
|
64
|
-
process.stderr.write(renderError(env) + "\n");
|
|
65
|
-
}
|
|
66
|
-
else {
|
|
67
|
-
process.stderr.write(JSON.stringify(env) + "\n");
|
|
68
|
-
}
|
|
69
|
-
process.exit(exitCode);
|
|
70
|
-
}
|
|
71
|
-
/** Re-export for verb-local convenience. */
|
|
72
|
-
export { CLI_VERSION };
|
|
@@ -1,152 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,109 +0,0 @@
|
|
|
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
|
-
}
|
package/dist/tools/api.js
DELETED
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `echopai api call <method> <path>` — DEPRECATED alias of `echopai raw call`.
|
|
3
|
-
*
|
|
4
|
-
* 保留一个 release 给老脚本,新代码请用 `echopai raw call`。
|
|
5
|
-
* 行为同 raw call,但在调用时往 stderr 打 deprecation 提示一行。
|
|
6
|
-
*/
|
|
7
|
-
import { Command } from "commander";
|
|
8
|
-
import { fetch as undiciFetch, Headers } from "undici";
|
|
9
|
-
import { resolveCredentials, AuthMissingError } from "../runtime/auth.js";
|
|
10
|
-
import { CLI_VERSION } from "../version.js";
|
|
11
|
-
export function buildApiCommand() {
|
|
12
|
-
const api = new Command("api").description("[DEPRECATED] Use `echopai raw call` instead — alias kept for one release");
|
|
13
|
-
api
|
|
14
|
-
.command("call <method> <path>")
|
|
15
|
-
.description("[DEPRECATED] Call <method> <path> — use `echopai raw call` instead")
|
|
16
|
-
.option("-d, --data <json>", "POST/PUT/PATCH body (JSON string)")
|
|
17
|
-
.option("-q, --query <kv...>", "Query string entries: k=v")
|
|
18
|
-
.option("--header <kv...>", "Extra headers: K=V")
|
|
19
|
-
.action(async (method, urlPath, opts) => {
|
|
20
|
-
process.stderr.write("[deprecated] `echopai api call` will be removed next major. Use `echopai raw call` instead.\n");
|
|
21
|
-
const m = method.toUpperCase();
|
|
22
|
-
if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(m)) {
|
|
23
|
-
return die("invalid_args", `Unknown HTTP method: ${method}`, 1);
|
|
24
|
-
}
|
|
25
|
-
let creds;
|
|
26
|
-
try {
|
|
27
|
-
creds = resolveCredentials({});
|
|
28
|
-
}
|
|
29
|
-
catch (e) {
|
|
30
|
-
if (e instanceof AuthMissingError) {
|
|
31
|
-
return die("auth_missing", e.message, 1, e.recovery_hint);
|
|
32
|
-
}
|
|
33
|
-
throw e;
|
|
34
|
-
}
|
|
35
|
-
let url = creds.baseUrl + (urlPath.startsWith("/") ? urlPath : "/" + urlPath);
|
|
36
|
-
if (opts.query?.length) {
|
|
37
|
-
const qs = opts.query
|
|
38
|
-
.map((kv) => {
|
|
39
|
-
const idx = kv.indexOf("=");
|
|
40
|
-
if (idx === -1)
|
|
41
|
-
return null;
|
|
42
|
-
return `${encodeURIComponent(kv.slice(0, idx))}=${encodeURIComponent(kv.slice(idx + 1))}`;
|
|
43
|
-
})
|
|
44
|
-
.filter((s) => s !== null)
|
|
45
|
-
.join("&");
|
|
46
|
-
if (qs)
|
|
47
|
-
url += (url.includes("?") ? "&" : "?") + qs;
|
|
48
|
-
}
|
|
49
|
-
const headers = new Headers({
|
|
50
|
-
Authorization: `Bearer ${creds.key}`,
|
|
51
|
-
"User-Agent": `echopai-cli/${CLI_VERSION}`,
|
|
52
|
-
Accept: "application/json",
|
|
53
|
-
});
|
|
54
|
-
if (opts.header) {
|
|
55
|
-
for (const kv of opts.header) {
|
|
56
|
-
const idx = kv.indexOf("=");
|
|
57
|
-
if (idx === -1)
|
|
58
|
-
continue;
|
|
59
|
-
headers.set(kv.slice(0, idx), kv.slice(idx + 1));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
const init = { method: m, headers };
|
|
63
|
-
if (opts.data) {
|
|
64
|
-
if (!headers.has("content-type"))
|
|
65
|
-
headers.set("content-type", "application/json");
|
|
66
|
-
init.body = opts.data;
|
|
67
|
-
}
|
|
68
|
-
const res = await undiciFetch(url, init);
|
|
69
|
-
const body = await res.text();
|
|
70
|
-
process.stdout.write(body + (body.endsWith("\n") ? "" : "\n"));
|
|
71
|
-
process.exit(res.status >= 400 && res.status < 500 ? 1 : res.status >= 500 ? 2 : 0);
|
|
72
|
-
});
|
|
73
|
-
return api;
|
|
74
|
-
}
|
|
75
|
-
function die(code, message, exitCode, recovery_hint) {
|
|
76
|
-
const env = { error: { code, message } };
|
|
77
|
-
if (recovery_hint)
|
|
78
|
-
env.error.recovery_hint = recovery_hint;
|
|
79
|
-
process.stderr.write(JSON.stringify(env) + "\n");
|
|
80
|
-
process.exit(exitCode);
|
|
81
|
-
}
|