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
package/dist/bin.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* EchoPai CLI v1 entry.
|
|
4
|
+
*
|
|
5
|
+
* Layered:
|
|
6
|
+
* bin.ts — entry, build commander tree (≤50 lines logic)
|
|
7
|
+
* _generated/commands.ts — spec-derived command tree (DO NOT EDIT)
|
|
8
|
+
* runtime/* — invoker / auth / paginator / format / errors
|
|
9
|
+
* tools/* — hand-curated UX (login / status / config / api / schema / completion)
|
|
10
|
+
*
|
|
11
|
+
* Persona detection:
|
|
12
|
+
* - TTY + !CI → human mode (table default, color, spinner)
|
|
13
|
+
* - else → AI/script mode (NDJSON / JSON, no color, no spinner)
|
|
14
|
+
*
|
|
15
|
+
* See docs/PLAN_CLI_V2_REWRITE.md for design details.
|
|
16
|
+
*/
|
|
17
|
+
import { Command, Option } from "commander";
|
|
18
|
+
import { buildCommandTree } from "./_generated/commands.js";
|
|
19
|
+
import { invoke } from "./runtime/invoker.js";
|
|
20
|
+
import { buildApiCommand } from "./tools/api.js";
|
|
21
|
+
import { buildMcpCommand } from "./tools/mcp.js";
|
|
22
|
+
import { buildRawCallCommand } from "./tools/raw.js";
|
|
23
|
+
import { buildBarsBatchCommand, buildChartCommand, buildDigestCommand, buildHotCommand, buildLookupCommand, buildNewsCommand, buildQuoteCommand, buildResearchCommand, buildScanCommand, buildSentimentCommand, buildViewsCommand, } from "./verbs/index.js";
|
|
24
|
+
import { buildCompletionCommand } from "./tools/completion.js";
|
|
25
|
+
import { buildConfigCommand } from "./tools/config.js";
|
|
26
|
+
import { buildLoginCommand, buildLogoutCommand, buildStatusCommand } from "./tools/login.js";
|
|
27
|
+
import { buildDoctorCommand } from "./tools/doctor.js";
|
|
28
|
+
import { buildSchemaCommand } from "./tools/schema.js";
|
|
29
|
+
import { buildTraceCommand } from "./tools/trace.js";
|
|
30
|
+
import { buildWhoamiCommand } from "./tools/whoami.js";
|
|
31
|
+
import { CLI_VERSION } from "./version.js";
|
|
32
|
+
const program = new Command();
|
|
33
|
+
program
|
|
34
|
+
.name("echopai")
|
|
35
|
+
.description("EchoPai CLI v1 — partner-grade access to stock-market data, news, sentiment,\n" +
|
|
36
|
+
"views, signals, backtest.\n\n" +
|
|
37
|
+
"AI-First: JSON envelope on stdout, JSON errors on stderr, three-state exit\n" +
|
|
38
|
+
"codes (0 success / 1 user error / 2 service error). Set ECHOPAI_KEY env\n" +
|
|
39
|
+
"var or run `echopai login`.")
|
|
40
|
+
.version(CLI_VERSION, "-V, --version")
|
|
41
|
+
// 注意(v1.0.1 hotfix):不再声明全局 --key / --profile —— 它们跟 `echopai login
|
|
42
|
+
// --key` / `echopai status --profile` 等子命令的同名 option 冲突(commander 会
|
|
43
|
+
// 把值吞给最近的解析器导致子命令看不到 → required / value missing)。
|
|
44
|
+
// 凭据覆盖改用 ECHOPAI_KEY / ECHOPAI_PROFILE env var;spec-driven 子命令的
|
|
45
|
+
// dispatch 自动从 env 读取 cred(runtime/auth.ts)。
|
|
46
|
+
.addOption(new Option("--debug", "Print HTTP wire trace to stderr (Bearer redacted)"))
|
|
47
|
+
.addOption(new Option("--raw", "Pass through raw response body (skip envelope wrap)"))
|
|
48
|
+
.addOption(new Option("--jq <jmespath>", "Transform data with JMESPath expression (renamed from --query to avoid clash with news.search etc.)"))
|
|
49
|
+
.addOption(new Option("--fields <a,b,c>", "Keep only listed top-level fields per item"))
|
|
50
|
+
.addOption(new Option("--max-bytes <n>", "Truncate serialized envelope to N bytes (sets meta.truncated)"))
|
|
51
|
+
.addOption(new Option("--yes", "Confirm a write op in non-TTY mode (required for sideEffect=write in agents / CI)"))
|
|
52
|
+
.addOption(new Option("--dry-run", "Send X-Dry-Run:1 on write ops where the server advertises dry-run support"))
|
|
53
|
+
.addHelpText("after", "\nAuthentication: ECHOPAI_KEY=<eps_live_<lookup>_<secret>> echopai <cmd>\n" +
|
|
54
|
+
"Raw mirror: echopai raw <noun> <verb> # all OpenAPI ops, e.g. raw news search\n" +
|
|
55
|
+
"Curated verbs: echopai <verb> # task-level entry (Phase 3.6+)\n" +
|
|
56
|
+
"Capability: echopai whoami | echopai doctor\n" +
|
|
57
|
+
"Schema dump: echopai schema export # AI agents pipe this for command surface\n" +
|
|
58
|
+
"Profile: echopai config profile add prod --base-url https://api.echopai.com\n");
|
|
59
|
+
const dispatch = async (op, args) => {
|
|
60
|
+
// commander stores global flags on the *root* command's opts in camelCase
|
|
61
|
+
// (e.g. --max-bytes → maxBytes). Subcommand-level opts go through
|
|
62
|
+
// camelToSnake inside attachOperation; mirror that here so runtime sees
|
|
63
|
+
// consistent snake_case (max_bytes / max_pages / max_items …).
|
|
64
|
+
const globals = program.opts();
|
|
65
|
+
const globalsSnake = {};
|
|
66
|
+
for (const [k, v] of Object.entries(globals)) {
|
|
67
|
+
globalsSnake[k.replace(/[A-Z]/g, (m) => "_" + m.toLowerCase())] = v;
|
|
68
|
+
}
|
|
69
|
+
await invoke(op, { ...globalsSnake, ...args }, { cliVersion: CLI_VERSION });
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* Replace commander's plain-text "error: ..." stderr output with a JSON
|
|
73
|
+
* envelope so AI parsers see only valid JSON on stderr.
|
|
74
|
+
*/
|
|
75
|
+
function applyAiFirstHooks(cmd) {
|
|
76
|
+
cmd.configureOutput({ writeErr: () => { } });
|
|
77
|
+
cmd.exitOverride((err) => {
|
|
78
|
+
// Help / version display exits 0 quietly.
|
|
79
|
+
if (err.code === "commander.helpDisplayed" ||
|
|
80
|
+
err.code === "commander.help" ||
|
|
81
|
+
err.code === "commander.version") {
|
|
82
|
+
process.exit(0);
|
|
83
|
+
}
|
|
84
|
+
// commander.unknownCommand / .missingArgument / .missingMandatoryOptionValue
|
|
85
|
+
// / .invalidArgument / .conflictingOption etc.
|
|
86
|
+
process.stderr.write(JSON.stringify({
|
|
87
|
+
error: {
|
|
88
|
+
code: "invalid_args",
|
|
89
|
+
message: err.message,
|
|
90
|
+
recovery_hint: "Run with `--help` for usage.",
|
|
91
|
+
},
|
|
92
|
+
}) + "\n");
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
95
|
+
for (const sub of cmd.commands)
|
|
96
|
+
applyAiFirstHooks(sub);
|
|
97
|
+
}
|
|
98
|
+
// Spec-driven raw mirror (Phase 3.5): all OpenAPI operations under `raw`
|
|
99
|
+
// namespace. The top level is reserved for curated agent verbs.
|
|
100
|
+
//
|
|
101
|
+
// Phase 3.7+ — `raw` is now the only place to find spec-driven operations.
|
|
102
|
+
// Top-level previously held spec mirrors but starting this PR the curated
|
|
103
|
+
// verbs (`views` / `news` / `quote` / `sentiment` / `research` / `hot` /
|
|
104
|
+
// `lookup` / `digest` etc.) take precedence at the top level. Use `echopai raw <noun>
|
|
105
|
+
// <verb>` for the raw mirror (e.g. `raw views feed`, `raw quote`, etc.).
|
|
106
|
+
const rawCmd = new Command("raw").description("Raw 1:1 mirror of OpenAPI operations (escape hatch for power users / scripts)");
|
|
107
|
+
buildCommandTree(rawCmd, dispatch);
|
|
108
|
+
rawCmd.addCommand(buildRawCallCommand());
|
|
109
|
+
program.addCommand(rawCmd);
|
|
110
|
+
// Top-level spec commands kept ONLY for nouns that don't conflict with a
|
|
111
|
+
// curated verb. Curated-verb nouns (views/news/quote/sentiment/research/hot
|
|
112
|
+
// /lookup/digest) are NOT generated at top level — they go below in their canonical
|
|
113
|
+
// curated form. Use `echopai raw <noun>` for the spec mirror.
|
|
114
|
+
const CURATED_NOUNS = new Set([
|
|
115
|
+
"views",
|
|
116
|
+
"news",
|
|
117
|
+
"quote",
|
|
118
|
+
"sentiment",
|
|
119
|
+
"research",
|
|
120
|
+
"hot",
|
|
121
|
+
"lookup",
|
|
122
|
+
"digest",
|
|
123
|
+
]);
|
|
124
|
+
const topLevelSpecDispatch = async (op, args) => {
|
|
125
|
+
await dispatch(op, args);
|
|
126
|
+
};
|
|
127
|
+
const topLevelStub = new Command();
|
|
128
|
+
buildCommandTree(topLevelStub, topLevelSpecDispatch);
|
|
129
|
+
for (const sub of topLevelStub.commands) {
|
|
130
|
+
if (CURATED_NOUNS.has(sub.name()))
|
|
131
|
+
continue;
|
|
132
|
+
program.addCommand(sub);
|
|
133
|
+
}
|
|
134
|
+
// Curated agent verbs (Phase 3.6+): task-level entries that wrap one or
|
|
135
|
+
// more raw operations + map output for AI agents. Listed before tools so
|
|
136
|
+
// `--help` shows them first.
|
|
137
|
+
// Product priority (see feedback_views_over_news.md): views > research > news
|
|
138
|
+
program.addCommand(buildLookupCommand());
|
|
139
|
+
program.addCommand(buildDigestCommand()); // one-shot fan-out (Phase 5.1)
|
|
140
|
+
program.addCommand(buildQuoteCommand());
|
|
141
|
+
program.addCommand(buildViewsCommand()); // PRIMARY research source
|
|
142
|
+
program.addCommand(buildResearchCommand()); // views quality signal layer
|
|
143
|
+
program.addCommand(buildNewsCommand()); // supplementary breadth
|
|
144
|
+
program.addCommand(buildSentimentCommand());
|
|
145
|
+
program.addCommand(buildHotCommand());
|
|
146
|
+
program.addCommand(buildChartCommand()); // single-code K-line
|
|
147
|
+
program.addCommand(buildBarsBatchCommand()); // multi-code batch K-line (PR 3.2/3.3 server)
|
|
148
|
+
program.addCommand(buildScanCommand()); // full-market scan (PR 3.4 server)
|
|
149
|
+
// hand-curated tools
|
|
150
|
+
program.addCommand(buildLoginCommand());
|
|
151
|
+
program.addCommand(buildLogoutCommand());
|
|
152
|
+
program.addCommand(buildStatusCommand());
|
|
153
|
+
program.addCommand(buildConfigCommand());
|
|
154
|
+
program.addCommand(buildApiCommand()); // deprecated alias of `raw call`; remove next major
|
|
155
|
+
program.addCommand(buildSchemaCommand());
|
|
156
|
+
program.addCommand(buildTraceCommand());
|
|
157
|
+
program.addCommand(buildWhoamiCommand());
|
|
158
|
+
program.addCommand(buildDoctorCommand());
|
|
159
|
+
program.addCommand(buildMcpCommand());
|
|
160
|
+
program.addCommand(buildCompletionCommand());
|
|
161
|
+
applyAiFirstHooks(program);
|
|
162
|
+
program.parseAsync(process.argv).catch((e) => {
|
|
163
|
+
process.stderr.write(JSON.stringify({
|
|
164
|
+
error: {
|
|
165
|
+
code: "internal_error",
|
|
166
|
+
message: e instanceof Error ? e.message : String(e),
|
|
167
|
+
},
|
|
168
|
+
}) + "\n");
|
|
169
|
+
process.exit(2);
|
|
170
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential / profile resolution.
|
|
3
|
+
*
|
|
4
|
+
* Precedence(高 → 低):
|
|
5
|
+
* 1. --key <eps_live_X_Y> 命令行
|
|
6
|
+
* 2. ECHOPAI_KEY env
|
|
7
|
+
* 3. ~/.config/echopai/config.toml 默认 profile
|
|
8
|
+
* 4. ~/.config/echopai/config.toml 指定 profile(--profile <name> 或 ECHOPAI_PROFILE)
|
|
9
|
+
* 5. 报错 auth_missing
|
|
10
|
+
*
|
|
11
|
+
* 配置文件格式(TOML):
|
|
12
|
+
*
|
|
13
|
+
* default_profile = "prod"
|
|
14
|
+
*
|
|
15
|
+
* [profiles.prod]
|
|
16
|
+
* key = "eps_live_xxx_yyy"
|
|
17
|
+
* base_url = "https://api.echopai.com"
|
|
18
|
+
*
|
|
19
|
+
* [profiles.staging]
|
|
20
|
+
* key = "eps_live_aaa_bbb"
|
|
21
|
+
* base_url = "https://staging.echopai.com"
|
|
22
|
+
*
|
|
23
|
+
* XDG paths:
|
|
24
|
+
* Linux/macOS: $XDG_CONFIG_HOME/echopai/config.toml or ~/.config/echopai/config.toml
|
|
25
|
+
* Windows: %APPDATA%/echopai/config.toml
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
|
|
31
|
+
export class AuthMissingError extends Error {
|
|
32
|
+
recovery_hint;
|
|
33
|
+
constructor(message, recovery_hint) {
|
|
34
|
+
super(message);
|
|
35
|
+
this.recovery_hint = recovery_hint;
|
|
36
|
+
this.name = "AuthMissingError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function configDir() {
|
|
40
|
+
if (process.platform === "win32") {
|
|
41
|
+
const appdata = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming");
|
|
42
|
+
return path.join(appdata, "echopai");
|
|
43
|
+
}
|
|
44
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
|
|
45
|
+
return path.join(xdg, "echopai");
|
|
46
|
+
}
|
|
47
|
+
export function configPath() {
|
|
48
|
+
return path.join(configDir(), "config.toml");
|
|
49
|
+
}
|
|
50
|
+
export function readConfigFile() {
|
|
51
|
+
const p = configPath();
|
|
52
|
+
if (!fs.existsSync(p))
|
|
53
|
+
return {};
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(p, "utf-8");
|
|
56
|
+
return parseToml(raw);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
export function writeConfigFile(cfg) {
|
|
63
|
+
const dir = configDir();
|
|
64
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
const data = stringifyToml(cfg);
|
|
66
|
+
fs.writeFileSync(configPath(), data, { mode: 0o600 });
|
|
67
|
+
}
|
|
68
|
+
export function resolveCredentials(opts = {}) {
|
|
69
|
+
const DEFAULT_BASE_URL = "https://api.echopai.com";
|
|
70
|
+
// 1. --key arg
|
|
71
|
+
if (opts.key) {
|
|
72
|
+
return { key: opts.key, baseUrl: process.env.ECHOPAI_BASE_URL || DEFAULT_BASE_URL, profile: null };
|
|
73
|
+
}
|
|
74
|
+
// 2. env var
|
|
75
|
+
const envKey = process.env.ECHOPAI_KEY;
|
|
76
|
+
if (envKey) {
|
|
77
|
+
return { key: envKey, baseUrl: process.env.ECHOPAI_BASE_URL || DEFAULT_BASE_URL, profile: null };
|
|
78
|
+
}
|
|
79
|
+
// 3+4. config file
|
|
80
|
+
const cfg = readConfigFile();
|
|
81
|
+
const profileName = opts.profile || process.env.ECHOPAI_PROFILE || cfg.default_profile;
|
|
82
|
+
if (profileName && cfg.profiles && cfg.profiles[profileName]) {
|
|
83
|
+
const p = cfg.profiles[profileName];
|
|
84
|
+
if (p.key) {
|
|
85
|
+
return {
|
|
86
|
+
key: p.key,
|
|
87
|
+
baseUrl: p.base_url || process.env.ECHOPAI_BASE_URL || DEFAULT_BASE_URL,
|
|
88
|
+
profile: profileName,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
throw new AuthMissingError(profileName
|
|
93
|
+
? `No key configured for profile '${profileName}'.`
|
|
94
|
+
: "No credential found.", "Set ECHOPAI_KEY env var, or run `echopai login --key eps_live_<lookup>_<secret>`.");
|
|
95
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard CLI success envelope.
|
|
3
|
+
*
|
|
4
|
+
* 服务端响应有时含 `{data, meta}`(pagination cursors / has_more / total 等),
|
|
5
|
+
* 有时只返 data。本模块统一对外输出:
|
|
6
|
+
*
|
|
7
|
+
* { data, meta: { ...serverMeta, request_id, endpoint, method, cli_version,
|
|
8
|
+
* api_version?, duration_ms, truncated } }
|
|
9
|
+
*
|
|
10
|
+
* CLI 字段永远覆盖 server 同名字段——CLI 是观察者,知道真相(例如 server 没 echo
|
|
11
|
+
* X-Request-Id 时,CLI 端的 uuid 才是 canonical)。
|
|
12
|
+
*
|
|
13
|
+
* truncated 默认 false;PR 1.4 引入 --max-bytes / --fields 等截断逻辑后,
|
|
14
|
+
* 触发时由 invoker / paginator 写为 true 并附 truncation_reason。
|
|
15
|
+
*
|
|
16
|
+
* 与 http.ts 关系:http.ts 出站(request headers),envelope.ts 入站(response meta)。
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Merge server-provided meta with CLI-known truth. CLI fields win on collision.
|
|
20
|
+
*/
|
|
21
|
+
export function mergeMeta(serverMeta, cli) {
|
|
22
|
+
const merged = {
|
|
23
|
+
...(serverMeta ?? {}),
|
|
24
|
+
request_id: cli.requestId,
|
|
25
|
+
endpoint: cli.endpoint,
|
|
26
|
+
method: cli.method,
|
|
27
|
+
cli_version: cli.cliVersion,
|
|
28
|
+
duration_ms: cli.durationMs,
|
|
29
|
+
truncated: false,
|
|
30
|
+
};
|
|
31
|
+
if (cli.apiVersion)
|
|
32
|
+
merged.api_version = cli.apiVersion;
|
|
33
|
+
return merged;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Build a complete response envelope from a raw response body.
|
|
37
|
+
*
|
|
38
|
+
* - 若 body 是 `{data, meta}` 形态:data 透传,meta 合并 CLI 字段。
|
|
39
|
+
* - 否则:整 body 当 data,meta 全部由 CLI 提供。
|
|
40
|
+
* - body 为 null/字符串/数组时也走"整 body 当 data"分支。
|
|
41
|
+
*/
|
|
42
|
+
export function buildResponseEnvelope(body, cli) {
|
|
43
|
+
if (typeof body === "object" &&
|
|
44
|
+
body !== null &&
|
|
45
|
+
!Array.isArray(body) &&
|
|
46
|
+
"data" in body &&
|
|
47
|
+
"meta" in body) {
|
|
48
|
+
const e = body;
|
|
49
|
+
return { data: e.data, meta: mergeMeta(e.meta, cli) };
|
|
50
|
+
}
|
|
51
|
+
return { data: body, meta: mergeMeta(undefined, cli) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 错误层:解析 API envelope,分级 exit code,输出 stderr JSON。
|
|
3
|
+
*
|
|
4
|
+
* Canonical envelope (server + CLI uniform):
|
|
5
|
+
* { "error": {
|
|
6
|
+
* "code": "<machine_code>",
|
|
7
|
+
* "message": "<human>",
|
|
8
|
+
* "retryable": <bool>,
|
|
9
|
+
* "recovery_hint": "<actionable>",
|
|
10
|
+
* "request_id": "<uuid>"
|
|
11
|
+
* } }
|
|
12
|
+
*
|
|
13
|
+
* `code` must be a member of KNOWN_ERROR_CODES (see docs/api-contract/error-codes.md).
|
|
14
|
+
* Server may emit codes outside this set; CLI accepts them but logs a warning under
|
|
15
|
+
* --debug. `recovery_hint` falls back to DEFAULT_RECOVERY_HINTS when server omits it.
|
|
16
|
+
*
|
|
17
|
+
* Exit code 三态:
|
|
18
|
+
* 0 success
|
|
19
|
+
* 1 user error (4xx — auth_missing / scope_insufficient / kind_not_allowed /
|
|
20
|
+
* invalid_args / validation_failed / not_found / confirmation_required ...)
|
|
21
|
+
* 2 service error (5xx, 429 retryable, network failure, internal_error, stream_error)
|
|
22
|
+
*/
|
|
23
|
+
export const KNOWN_ERROR_CODES = [
|
|
24
|
+
// auth / identity
|
|
25
|
+
"auth_missing",
|
|
26
|
+
"auth_invalid",
|
|
27
|
+
"auth_expired",
|
|
28
|
+
// permission / scope
|
|
29
|
+
"scope_insufficient",
|
|
30
|
+
"kind_not_allowed",
|
|
31
|
+
"channel_not_allowed",
|
|
32
|
+
// input / validation
|
|
33
|
+
"invalid_args",
|
|
34
|
+
"validation_failed",
|
|
35
|
+
"not_found",
|
|
36
|
+
"idempotency_conflict",
|
|
37
|
+
// write / confirmation
|
|
38
|
+
"confirmation_required",
|
|
39
|
+
"dry_run_unsupported",
|
|
40
|
+
"unsafe_command",
|
|
41
|
+
// rate / quota
|
|
42
|
+
"rate_limited",
|
|
43
|
+
"quota_exhausted",
|
|
44
|
+
"agent_budget_exhausted",
|
|
45
|
+
// transport / infrastructure
|
|
46
|
+
"network_error",
|
|
47
|
+
"timeout",
|
|
48
|
+
"stream_error",
|
|
49
|
+
"upstream_unavailable",
|
|
50
|
+
// server / internal
|
|
51
|
+
"internal_error",
|
|
52
|
+
"http_error",
|
|
53
|
+
];
|
|
54
|
+
const KNOWN_CODE_SET = new Set(KNOWN_ERROR_CODES);
|
|
55
|
+
export function isKnownErrorCode(code) {
|
|
56
|
+
return KNOWN_CODE_SET.has(code);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Codes that are safe to retry with the same input. Server may also set
|
|
60
|
+
* `retryable` per-response; if it does, server wins. This map is the
|
|
61
|
+
* fallback when server omits `retryable`.
|
|
62
|
+
*/
|
|
63
|
+
const RETRYABLE_CODES = new Set([
|
|
64
|
+
"rate_limited",
|
|
65
|
+
"network_error",
|
|
66
|
+
"timeout",
|
|
67
|
+
"stream_error",
|
|
68
|
+
"upstream_unavailable",
|
|
69
|
+
"internal_error",
|
|
70
|
+
]);
|
|
71
|
+
export function isRetryableByCode(code) {
|
|
72
|
+
return RETRYABLE_CODES.has(code);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Default recovery hints when server omits `recovery_hint`. Keep in sync with
|
|
76
|
+
* docs/api-contract/error-codes.md.
|
|
77
|
+
*/
|
|
78
|
+
export const DEFAULT_RECOVERY_HINTS = {
|
|
79
|
+
auth_missing: "Run `echopai login`, or set `ECHOPAI_KEY=<token>`.",
|
|
80
|
+
auth_invalid: "Token rejected. Re-issue via `echopai login` or check token revocation.",
|
|
81
|
+
auth_expired: "Token expired. Re-issue via `echopai login`.",
|
|
82
|
+
scope_insufficient: "Run `echopai whoami` to see available scopes; request the missing scope from your app admin.",
|
|
83
|
+
kind_not_allowed: "Endpoint not available for this token kind. See `echopai whoami` for your kind.",
|
|
84
|
+
channel_not_allowed: "This CLI/MCP channel is not authorized for the current token. Check `allowed_clients` on your app config.",
|
|
85
|
+
invalid_args: "Check parameter types/format. Run with `--help` for the schema.",
|
|
86
|
+
validation_failed: "Server rejected the request body. Check the `message` for the offending field.",
|
|
87
|
+
not_found: "Resource does not exist or is not visible to this token.",
|
|
88
|
+
idempotency_conflict: "Same Idempotency-Key seen with a different body. Use a new key or replay the original request exactly.",
|
|
89
|
+
confirmation_required: "Non-TTY write requires `--yes`. Re-run with `--yes` after verifying the operation.",
|
|
90
|
+
dry_run_unsupported: "This endpoint does not support `--dry-run`. Run without `--dry-run` (and `--yes` if it's a write).",
|
|
91
|
+
unsafe_command: "This command is gated. See `docs/PLAN_CLI_V2_AGENT_SURFACE.md` §6 for safety model.",
|
|
92
|
+
rate_limited: "Slow down. Honor `Retry-After` header if present; back off and retry.",
|
|
93
|
+
quota_exhausted: "Monthly quota for this endpoint class exceeded. Upgrade plan or wait until reset.",
|
|
94
|
+
agent_budget_exhausted: "Agent budget exhausted. Start a new session via `agent session-start` or request a budget bump.",
|
|
95
|
+
network_error: "Verify network reachability and `base_url`. Use `--debug` for full trace.",
|
|
96
|
+
timeout: "Server took too long. Retry; if persistent, check `echopai doctor`.",
|
|
97
|
+
stream_error: "WebSocket closed abnormally. Use `--debug` to see the close code/reason; `--reconnect` to auto-retry.",
|
|
98
|
+
upstream_unavailable: "Upstream service is temporarily unavailable. Retry with backoff.",
|
|
99
|
+
internal_error: "Server-side error. Retry; include the `request_id` when reporting.",
|
|
100
|
+
http_error: "Server did not return a typed error envelope. See `message` and `request_id`.",
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* Resolve effective recovery hint:
|
|
104
|
+
* 1. Explicit hint (server-provided or CLI-passed)
|
|
105
|
+
* 2. DEFAULT_RECOVERY_HINTS[code] fallback
|
|
106
|
+
* 3. undefined if code unknown and no hint
|
|
107
|
+
*/
|
|
108
|
+
export function resolveRecoveryHint(code, explicit) {
|
|
109
|
+
if (explicit && explicit.length > 0)
|
|
110
|
+
return explicit;
|
|
111
|
+
if (isKnownErrorCode(code))
|
|
112
|
+
return DEFAULT_RECOVERY_HINTS[code];
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
export class CallApiError extends Error {
|
|
116
|
+
code;
|
|
117
|
+
retryable;
|
|
118
|
+
recovery_hint;
|
|
119
|
+
httpStatus;
|
|
120
|
+
requestId;
|
|
121
|
+
raw;
|
|
122
|
+
constructor(args) {
|
|
123
|
+
super(args.message);
|
|
124
|
+
this.name = "CallApiError";
|
|
125
|
+
this.code = args.code;
|
|
126
|
+
this.retryable = args.retryable ?? isRetryableByCode(args.code);
|
|
127
|
+
this.recovery_hint = resolveRecoveryHint(args.code, args.recovery_hint);
|
|
128
|
+
this.httpStatus = args.httpStatus;
|
|
129
|
+
this.requestId = args.requestId;
|
|
130
|
+
this.raw = args.raw;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Parse possibly-envelope JSON body. Returns CallApiError if it looks like a
|
|
135
|
+
* standard EchoPai error, else null.
|
|
136
|
+
*/
|
|
137
|
+
export function tryParseErrorEnvelope(body, httpStatus, requestId) {
|
|
138
|
+
if (typeof body !== "object" || body === null)
|
|
139
|
+
return null;
|
|
140
|
+
const obj = body;
|
|
141
|
+
const direct = errorFromObject(obj.error, body, httpStatus, requestId);
|
|
142
|
+
if (direct)
|
|
143
|
+
return direct;
|
|
144
|
+
// FastAPI sometimes wraps the service envelope as {detail: {error: {...}}}.
|
|
145
|
+
if (typeof obj.detail === "object" && obj.detail !== null) {
|
|
146
|
+
const detail = obj.detail;
|
|
147
|
+
const nested = errorFromObject(detail.error, body, httpStatus, requestId);
|
|
148
|
+
if (nested)
|
|
149
|
+
return nested;
|
|
150
|
+
}
|
|
151
|
+
// FastAPI fallback {detail: "..."}
|
|
152
|
+
if (typeof obj.detail === "string") {
|
|
153
|
+
return new CallApiError({
|
|
154
|
+
code: httpStatus === 401 ? "auth_missing" : "http_error",
|
|
155
|
+
message: obj.detail,
|
|
156
|
+
httpStatus,
|
|
157
|
+
requestId,
|
|
158
|
+
raw: body,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
function errorFromObject(value, raw, httpStatus, requestId) {
|
|
164
|
+
if (typeof value !== "object" || value === null)
|
|
165
|
+
return null;
|
|
166
|
+
const env = value;
|
|
167
|
+
if (typeof env.code !== "string" || typeof env.message !== "string") {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return new CallApiError({
|
|
171
|
+
code: env.code,
|
|
172
|
+
message: env.message,
|
|
173
|
+
...(typeof env.retryable === "boolean" ? { retryable: env.retryable } : {}),
|
|
174
|
+
...(typeof env.recovery_hint === "string"
|
|
175
|
+
? { recovery_hint: env.recovery_hint }
|
|
176
|
+
: {}),
|
|
177
|
+
httpStatus,
|
|
178
|
+
requestId,
|
|
179
|
+
raw,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
export function exitCodeForStatus(httpStatus) {
|
|
183
|
+
if (httpStatus >= 400 && httpStatus < 500)
|
|
184
|
+
return 1;
|
|
185
|
+
return 2;
|
|
186
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Output filter pipeline applied between response envelope and render.
|
|
3
|
+
*
|
|
4
|
+
* Pipeline order (each step optional, --flag triggers):
|
|
5
|
+
* 1. --query <jmespath> data = jmespath.search(data, expr)
|
|
6
|
+
* 2. --fields a,b,c keep only these keys per item/object
|
|
7
|
+
* 3. --max-bytes N binary-search items truncation; meta.truncated=true
|
|
8
|
+
*
|
|
9
|
+
* 设计原则:
|
|
10
|
+
* - JMESPath 错误算 user error(exit 1),调用方负责捕获并 writeError("invalid_args")。
|
|
11
|
+
* - --fields 对 array / {items:[]} / 普通 object 都做 best-effort projection。
|
|
12
|
+
* - --max-bytes 是 serialized envelope 的字节数;items 数组形态可智能裁剪,
|
|
13
|
+
* 其他形态退化为整 data 替换为 omitted 占位符。
|
|
14
|
+
* - 截断时 meta.truncated 翻为 true,加 truncation_reason / truncated_items_dropped。
|
|
15
|
+
*/
|
|
16
|
+
import jmespathPkg from "jmespath";
|
|
17
|
+
const jmes = jmespathPkg;
|
|
18
|
+
const jmesSearch = jmes.search ?? (jmes.default ? jmes.default.search : (() => null));
|
|
19
|
+
export class FilterError extends Error {
|
|
20
|
+
constructor(message) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.name = "FilterError";
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function applyFilters(envelope, opts) {
|
|
26
|
+
let data = envelope.data;
|
|
27
|
+
let meta = { ...envelope.meta };
|
|
28
|
+
if (opts.query && opts.query.length > 0) {
|
|
29
|
+
try {
|
|
30
|
+
data = jmesSearch(data, opts.query);
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
throw new FilterError(`Invalid --query expression: ${e instanceof Error ? e.message : String(e)}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (opts.fields && opts.fields.length > 0) {
|
|
37
|
+
data = pickFields(data, opts.fields);
|
|
38
|
+
}
|
|
39
|
+
if (opts.maxBytes !== undefined && opts.maxBytes > 0) {
|
|
40
|
+
return enforceMaxBytes({ data, meta }, opts.maxBytes);
|
|
41
|
+
}
|
|
42
|
+
return { data, meta };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Recursively project: array → map; {items:[...]} → recurse into items;
|
|
46
|
+
* plain object → pick listed keys; primitive → unchanged.
|
|
47
|
+
*/
|
|
48
|
+
export function pickFields(value, fields) {
|
|
49
|
+
if (Array.isArray(value)) {
|
|
50
|
+
return value.map((item) => pickFields(item, fields));
|
|
51
|
+
}
|
|
52
|
+
if (value !== null && typeof value === "object") {
|
|
53
|
+
const obj = value;
|
|
54
|
+
if (Array.isArray(obj.items)) {
|
|
55
|
+
return {
|
|
56
|
+
...obj,
|
|
57
|
+
items: obj.items.map((it) => pickFields(it, fields)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const f of fields) {
|
|
62
|
+
if (f in obj)
|
|
63
|
+
out[f] = obj[f];
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
function enforceMaxBytes(env, maxBytes) {
|
|
70
|
+
const initialSize = JSON.stringify(env).length;
|
|
71
|
+
if (initialSize <= maxBytes)
|
|
72
|
+
return env;
|
|
73
|
+
const data = env.data;
|
|
74
|
+
// Truncatable shape 1: object with {items: [...]} (envelope-style)
|
|
75
|
+
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
|
|
76
|
+
const obj = data;
|
|
77
|
+
if (Array.isArray(obj.items)) {
|
|
78
|
+
const items = obj.items;
|
|
79
|
+
const total = items.length;
|
|
80
|
+
const buildEnv = (n) => ({
|
|
81
|
+
data: { ...obj, items: items.slice(0, n) },
|
|
82
|
+
meta: {
|
|
83
|
+
...env.meta,
|
|
84
|
+
truncated: true,
|
|
85
|
+
truncation_reason: "max_bytes",
|
|
86
|
+
truncated_items_dropped: total - n,
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const fit = binarySearchFit(buildEnv, total, maxBytes);
|
|
90
|
+
return buildEnv(fit);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Truncatable shape 2: top-level array
|
|
94
|
+
if (Array.isArray(data)) {
|
|
95
|
+
const total = data.length;
|
|
96
|
+
const buildEnv = (n) => ({
|
|
97
|
+
data: data.slice(0, n),
|
|
98
|
+
meta: {
|
|
99
|
+
...env.meta,
|
|
100
|
+
truncated: true,
|
|
101
|
+
truncation_reason: "max_bytes",
|
|
102
|
+
truncated_items_dropped: total - n,
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
const fit = binarySearchFit(buildEnv, total, maxBytes);
|
|
106
|
+
return buildEnv(fit);
|
|
107
|
+
}
|
|
108
|
+
// Non-truncatable: replace data with placeholder
|
|
109
|
+
return {
|
|
110
|
+
data: {
|
|
111
|
+
omitted: true,
|
|
112
|
+
reason: "response exceeded --max-bytes; data is not paginatable",
|
|
113
|
+
},
|
|
114
|
+
meta: {
|
|
115
|
+
...env.meta,
|
|
116
|
+
truncated: true,
|
|
117
|
+
truncation_reason: "max_bytes",
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function binarySearchFit(shape, total, maxBytes) {
|
|
122
|
+
// Find largest n in [0, total] where JSON.stringify(shape(n)).length <= maxBytes
|
|
123
|
+
let lo = 0;
|
|
124
|
+
let hi = total;
|
|
125
|
+
while (lo < hi) {
|
|
126
|
+
const mid = Math.floor((lo + hi + 1) / 2);
|
|
127
|
+
const size = JSON.stringify(shape(mid)).length;
|
|
128
|
+
if (size <= maxBytes)
|
|
129
|
+
lo = mid;
|
|
130
|
+
else
|
|
131
|
+
hi = mid - 1;
|
|
132
|
+
}
|
|
133
|
+
return lo;
|
|
134
|
+
}
|
|
135
|
+
export function parseFieldsFlag(raw) {
|
|
136
|
+
if (typeof raw !== "string" || raw.length === 0)
|
|
137
|
+
return undefined;
|
|
138
|
+
const fields = raw
|
|
139
|
+
.split(",")
|
|
140
|
+
.map((s) => s.trim())
|
|
141
|
+
.filter((s) => s.length > 0);
|
|
142
|
+
return fields.length > 0 ? fields : undefined;
|
|
143
|
+
}
|
|
144
|
+
export function parseMaxBytesFlag(raw) {
|
|
145
|
+
if (typeof raw === "number" && raw > 0)
|
|
146
|
+
return raw;
|
|
147
|
+
if (typeof raw === "string" && raw.length > 0) {
|
|
148
|
+
const n = Number(raw);
|
|
149
|
+
if (Number.isFinite(n) && n > 0)
|
|
150
|
+
return n;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|