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,141 @@
1
+ /**
2
+ * `echopai mcp serve` — stdio MCP server.
3
+ *
4
+ * 启动流程:
5
+ * 1. 解析凭据 (ECHOPAI_KEY / ECHOPAI_PROFILE / --profile)
6
+ * 2. getWhoami(channel="mcp") 拿 token scopes
7
+ * 3. 用 OPERATIONS.scopesAny 把 ALL_VERB_SPECS 过滤到 token 可调子集
8
+ * 4. registerTool 每个可用 verb (Zod inputSchema 直接给 SDK)
9
+ * 5. connect stdio transport, 进 message loop
10
+ *
11
+ * Tool handler:
12
+ * - 收到 tools/call → 调 spec.handler(args, ctx) (channel="mcp" 注入 header)
13
+ * - 成功 → 返 MCP content block (JSON envelope 序列化为 text)
14
+ * - 失败 → CallApiError 映射为 MCP isError=true tool response
15
+ *
16
+ * 错误日志走 stderr (MCP stdio 协议: stdout 仅 JSON-RPC, stderr 可任意文本)。
17
+ */
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+ import { Command, Option } from "commander";
21
+ import { OPERATIONS } from "../_generated/operations.js";
22
+ import { resolveCredentials, AuthMissingError } from "../runtime/auth.js";
23
+ import { CallApiError } from "../runtime/errors.js";
24
+ import { getWhoami } from "../runtime/whoami_cache.js";
25
+ import { ALL_VERB_SPECS } from "../verbs/index.js";
26
+ import { CLI_VERSION } from "../version.js";
27
+ /** Verb is available iff at least one backing op is callable with token scopes. */
28
+ export function verbAvailable(spec, tokenScopes) {
29
+ if (spec.backingOps.length === 0)
30
+ return true;
31
+ for (const opKey of spec.backingOps) {
32
+ const op = OPERATIONS[opKey];
33
+ if (!op)
34
+ continue;
35
+ if (op.scopesAny.length === 0)
36
+ return true;
37
+ if (op.scopesAny.some((s) => tokenScopes.has(s)))
38
+ return true;
39
+ }
40
+ return false;
41
+ }
42
+ export function filterAvailableVerbs(specs, tokenScopes) {
43
+ return specs.filter((s) => verbAvailable(s, tokenScopes));
44
+ }
45
+ export function envelopeToToolResponse(envelope) {
46
+ return {
47
+ content: [{ type: "text", text: JSON.stringify(envelope) }],
48
+ };
49
+ }
50
+ export function errorToToolResponse(e) {
51
+ if (e instanceof CallApiError) {
52
+ const env = {
53
+ error: {
54
+ code: e.code,
55
+ message: e.message,
56
+ retryable: e.retryable,
57
+ ...(e.recovery_hint ? { recovery_hint: e.recovery_hint } : {}),
58
+ ...(e.requestId ? { request_id: e.requestId } : {}),
59
+ },
60
+ };
61
+ return { content: [{ type: "text", text: JSON.stringify(env) }], isError: true };
62
+ }
63
+ const env = {
64
+ error: {
65
+ code: "internal_error",
66
+ message: e instanceof Error ? e.message : String(e),
67
+ retryable: false,
68
+ },
69
+ };
70
+ return { content: [{ type: "text", text: JSON.stringify(env) }], isError: true };
71
+ }
72
+ export function buildMcpCommand() {
73
+ const mcp = new Command("mcp").description("Model Context Protocol bridge (expose curated verbs to MCP hosts)");
74
+ mcp
75
+ .command("serve")
76
+ .description("Run an MCP stdio server on this process. For Claude Desktop / Cursor / Claude Code.")
77
+ .addOption(new Option("--profile <name>", "Use a specific profile"))
78
+ .action(async (opts) => {
79
+ let creds;
80
+ try {
81
+ creds = resolveCredentials(opts.profile ? { profile: opts.profile } : {});
82
+ }
83
+ catch (e) {
84
+ if (e instanceof AuthMissingError) {
85
+ process.stderr.write(`[mcp] credential resolution failed: ${e.message}\n` +
86
+ `[mcp] hint: ${e.recovery_hint ?? "Run `echopai login` or set ECHOPAI_KEY."}\n`);
87
+ process.exit(1);
88
+ }
89
+ throw e;
90
+ }
91
+ let whoami;
92
+ try {
93
+ whoami = await getWhoami({
94
+ baseUrl: creds.baseUrl,
95
+ bearer: creds.key,
96
+ cliVersion: CLI_VERSION,
97
+ channel: "mcp",
98
+ });
99
+ }
100
+ catch (e) {
101
+ process.stderr.write(`[mcp] whoami failed: ${e instanceof Error ? e.message : String(e)}\n`);
102
+ process.exit(1);
103
+ }
104
+ const tokenScopes = new Set(whoami.scopes);
105
+ const availableVerbs = filterAvailableVerbs(ALL_VERB_SPECS, tokenScopes);
106
+ process.stderr.write(`[mcp] kind=${whoami.kind} scopes=[${whoami.scopes.join(",")}]\n` +
107
+ `[mcp] exposing ${availableVerbs.length}/${ALL_VERB_SPECS.length} curated verbs as MCP tools\n`);
108
+ const server = new McpServer({
109
+ name: "echopai",
110
+ version: CLI_VERSION,
111
+ title: "EchoPai",
112
+ }, {
113
+ capabilities: { tools: {} },
114
+ });
115
+ const handlerCtx = {
116
+ baseUrl: creds.baseUrl,
117
+ bearer: creds.key,
118
+ cliVersion: CLI_VERSION,
119
+ channel: "mcp",
120
+ };
121
+ for (const spec of availableVerbs) {
122
+ server.registerTool(spec.name, {
123
+ description: spec.description,
124
+ inputSchema: spec.inputSchema,
125
+ }, async (args) => {
126
+ try {
127
+ const envelope = await spec.handler(args, handlerCtx);
128
+ return envelopeToToolResponse(envelope);
129
+ }
130
+ catch (e) {
131
+ return errorToToolResponse(e);
132
+ }
133
+ });
134
+ }
135
+ const transport = new StdioServerTransport();
136
+ await server.connect(transport);
137
+ process.stderr.write("[mcp] connected on stdio; waiting for messages\n");
138
+ // The server runs until stdin closes; SDK handles shutdown.
139
+ });
140
+ return mcp;
141
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `echopai raw call <method> <path> [--data '{...}'] [--query k=v ...]`
3
+ *
4
+ * Raw HTTP passthrough — 给未 codegen 的端点 / debug / 应急用。
5
+ * 跳过 Ajv pre-flight 校验、跳过 envelope 解析;行为 = curl + Bearer 注入 + base URL。
6
+ *
7
+ * 自动注入 X-Client / X-Client-Channel / X-Request-Id (与 spec-driven 调用同源),
8
+ * 这样 server 端 audit 能识别这是 CLI 流量。
9
+ *
10
+ * `echopai api call ...` 保留为 deprecated alias 一个 release (tools/api.ts);
11
+ * 下一个 major 移除。
12
+ */
13
+ import { Command } from "commander";
14
+ import { fetch as undiciFetch } from "undici";
15
+ import { resolveCredentials, AuthMissingError } from "../runtime/auth.js";
16
+ import { buildHttpHeaders } from "../runtime/http.js";
17
+ import { CLI_VERSION } from "../version.js";
18
+ export function buildRawCallCommand() {
19
+ const call = new Command("call")
20
+ .description("Raw HTTP passthrough (arbitrary <method> <path>; debug / non-codegen endpoints)")
21
+ .argument("<method>", "HTTP method (GET|POST|PUT|PATCH|DELETE|HEAD)")
22
+ .argument("<path>", "URL path (relative; base URL injected from credential)")
23
+ .option("-d, --data <json>", "POST/PUT/PATCH body (JSON string)")
24
+ .option("-q, --query <kv...>", "Query string entries: k=v")
25
+ .option("--header <kv...>", "Extra headers: K=V");
26
+ call.action(async (method, urlPath, opts) => {
27
+ const m = method.toUpperCase();
28
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"].includes(m)) {
29
+ return die("invalid_args", `Unknown HTTP method: ${method}`, 1);
30
+ }
31
+ let creds;
32
+ try {
33
+ creds = resolveCredentials({});
34
+ }
35
+ catch (e) {
36
+ if (e instanceof AuthMissingError) {
37
+ return die("auth_missing", e.message, 1, e.recovery_hint);
38
+ }
39
+ throw e;
40
+ }
41
+ let url = creds.baseUrl + (urlPath.startsWith("/") ? urlPath : "/" + urlPath);
42
+ if (opts.query?.length) {
43
+ const qs = opts.query
44
+ .map((kv) => {
45
+ const idx = kv.indexOf("=");
46
+ if (idx === -1)
47
+ return null;
48
+ return `${encodeURIComponent(kv.slice(0, idx))}=${encodeURIComponent(kv.slice(idx + 1))}`;
49
+ })
50
+ .filter((s) => s !== null)
51
+ .join("&");
52
+ if (qs)
53
+ url += (url.includes("?") ? "&" : "?") + qs;
54
+ }
55
+ // Use centralized header builder (Phase 1.1) — automatically injects
56
+ // X-Client / X-Client-Channel / X-Request-Id. Raw passthrough should
57
+ // still be tagged as CLI traffic in server audit.
58
+ const { headers } = buildHttpHeaders({
59
+ bearer: creds.key,
60
+ cliVersion: CLI_VERSION,
61
+ });
62
+ if (opts.header) {
63
+ for (const kv of opts.header) {
64
+ const idx = kv.indexOf("=");
65
+ if (idx === -1)
66
+ continue;
67
+ headers.set(kv.slice(0, idx), kv.slice(idx + 1));
68
+ }
69
+ }
70
+ const init = { method: m, headers };
71
+ if (opts.data) {
72
+ if (!headers.has("content-type")) {
73
+ headers.set("content-type", "application/json");
74
+ }
75
+ init.body = opts.data;
76
+ }
77
+ const res = await undiciFetch(url, init);
78
+ const body = await res.text();
79
+ process.stdout.write(body + (body.endsWith("\n") ? "" : "\n"));
80
+ process.exit(res.status >= 400 && res.status < 500
81
+ ? 1
82
+ : res.status >= 500
83
+ ? 2
84
+ : 0);
85
+ });
86
+ return call;
87
+ }
88
+ function die(code, message, exitCode, recovery_hint) {
89
+ const env = {
90
+ error: { code, message, retryable: false },
91
+ };
92
+ if (recovery_hint)
93
+ env.error.recovery_hint = recovery_hint;
94
+ process.stderr.write(JSON.stringify(env) + "\n");
95
+ process.exit(exitCode);
96
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `echopai schema [list | get <key> | export]`
3
+ *
4
+ * AI agent ingestion 路径:`echopai schema export | ai-tool` 一次拿全部命令的
5
+ * input JSON Schema + path + method + 示例。
6
+ */
7
+ import { Command } from "commander";
8
+ import { OPERATIONS, listOperations } from "../_generated/operations.js";
9
+ import { HELP } from "../_generated/help.js";
10
+ export function buildSchemaCommand() {
11
+ const cmd = new Command("schema").description("Inspect / export the CLI command surface");
12
+ cmd
13
+ .command("list")
14
+ .description("Print one line per command: cliKey, method, path, summary")
15
+ .action(() => {
16
+ for (const op of listOperations()) {
17
+ process.stdout.write(JSON.stringify({
18
+ cliKey: op.cliKey,
19
+ method: op.method,
20
+ path: op.path,
21
+ summary: op.summary,
22
+ }) + "\n");
23
+ }
24
+ process.exit(0);
25
+ });
26
+ cmd
27
+ .command("get <cliKey>")
28
+ .description("Print full operation def + help (input schema + example)")
29
+ .action((cliKey) => {
30
+ const op = OPERATIONS[cliKey];
31
+ if (!op) {
32
+ process.stderr.write(JSON.stringify({
33
+ error: {
34
+ code: "schema_not_found",
35
+ message: `No command with cliKey '${cliKey}'.`,
36
+ recovery_hint: "Run `echopai schema list` for available keys.",
37
+ },
38
+ }) + "\n");
39
+ process.exit(1);
40
+ }
41
+ const help = HELP[cliKey] ?? null;
42
+ const out = { ...op, ...(help ? { example: help.example } : {}) };
43
+ process.stdout.write(JSON.stringify(out, null, 2) + "\n");
44
+ process.exit(0);
45
+ });
46
+ cmd
47
+ .command("export")
48
+ .description("NDJSON dump of full surface (one operation per line) — for AI agents")
49
+ .action(() => {
50
+ for (const op of listOperations()) {
51
+ const help = HELP[op.cliKey];
52
+ const merged = { ...op, ...(help ? { example: help.example } : {}) };
53
+ process.stdout.write(JSON.stringify(merged) + "\n");
54
+ }
55
+ process.exit(0);
56
+ });
57
+ return cmd;
58
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `echopai trace [tail | get <request_id>]`
3
+ *
4
+ * 本地调用追溯 ring buffer (~/.echopai/trace.ndjson)。每次 CLI 调用结束都会
5
+ * 写一条 NDJSON。用途:
6
+ * - 看最近 N 次调用 (tail)
7
+ * - 用 request_id 反查具体一次 (get)
8
+ *
9
+ * `ECHOPAI_NO_TRACE=1` 关闭写入;`ECHOPAI_TRACE_FILE=/path` 改路径(测试用)。
10
+ */
11
+ import { Command, Option } from "commander";
12
+ import { getTraceByRequestId, resolveTraceFile, tailTraces, } from "../runtime/trace.js";
13
+ export function buildTraceCommand() {
14
+ const cmd = new Command("trace").description("Inspect local CLI invocation trace (~/.echopai/trace.ndjson)");
15
+ cmd
16
+ .command("tail")
17
+ .description("Print last N trace records (NDJSON)")
18
+ .addOption(new Option("--lines <n>", "Number of records").default("20"))
19
+ .action((opts) => {
20
+ const n = Math.max(1, Math.floor(Number(opts.lines) || 20));
21
+ const records = tailTraces(n);
22
+ for (const r of records) {
23
+ process.stdout.write(JSON.stringify(r) + "\n");
24
+ }
25
+ process.exit(0);
26
+ });
27
+ cmd
28
+ .command("get <request_id>")
29
+ .description("Find a single trace record by request_id")
30
+ .action((requestId) => {
31
+ const r = getTraceByRequestId(requestId);
32
+ if (!r) {
33
+ process.stderr.write(JSON.stringify({
34
+ error: {
35
+ code: "not_found",
36
+ message: `No trace record with request_id '${requestId}'.`,
37
+ retryable: false,
38
+ recovery_hint: "Trace ring buffer holds ~50MB. Older records rotate to `trace.ndjson.1` and beyond that are lost.",
39
+ },
40
+ }) + "\n");
41
+ process.exit(1);
42
+ }
43
+ process.stdout.write(JSON.stringify(r, null, 2) + "\n");
44
+ process.exit(0);
45
+ });
46
+ cmd
47
+ .command("path")
48
+ .description("Print the resolved trace file path")
49
+ .action(() => {
50
+ process.stdout.write(resolveTraceFile() + "\n");
51
+ process.exit(0);
52
+ });
53
+ return cmd;
54
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * `echopai whoami`
3
+ *
4
+ * 调 /v1/auth/whoami 拿 token 自省 + 派生 operation 可用性:
5
+ * available: token 至少有一个 op.scopesAny 中的 scope (或 scopesAny 为空)
6
+ * unavailable: op.scopesAny 非空且全部 miss → missing_scopes_any 全部列出
7
+ *
8
+ * 输出标准 envelope;缓存 5min in-process (whoami_cache.ts)。
9
+ *
10
+ * 该命令是 Phase 4 MCP `tools/list` 推导逻辑的同源——MCP server 启动时调用
11
+ * 同一个 getWhoami() 拿能力图。
12
+ */
13
+ import { Command, Option } from "commander";
14
+ import { listOperations } from "../_generated/operations.js";
15
+ import { resolveCredentials, AuthMissingError } from "../runtime/auth.js";
16
+ import { CallApiError } from "../runtime/errors.js";
17
+ import { isTtyHuman, renderError } from "../runtime/tty.js";
18
+ import { clearWhoamiCache, getWhoami } from "../runtime/whoami_cache.js";
19
+ import { CLI_VERSION } from "../version.js";
20
+ export function deriveOperationAvailability(scopes, ops = listOperations()) {
21
+ const available = [];
22
+ const unavailable = [];
23
+ for (const op of ops) {
24
+ const required = op.scopesAny;
25
+ const summary = { cliKey: op.cliKey, method: op.method, path: op.path };
26
+ if (!required || required.length === 0) {
27
+ available.push(summary);
28
+ continue;
29
+ }
30
+ const hit = required.some((s) => scopes.has(s));
31
+ if (hit) {
32
+ available.push(summary);
33
+ }
34
+ else {
35
+ unavailable.push({ ...summary, missing_scopes_any: required.slice() });
36
+ }
37
+ }
38
+ return { available, unavailable };
39
+ }
40
+ export function buildWhoamiOutput(whoami, channel, ops = listOperations()) {
41
+ const scopeSet = new Set(whoami.scopes);
42
+ const { available, unavailable } = deriveOperationAvailability(scopeSet, ops);
43
+ const out = {
44
+ kind: whoami.kind,
45
+ scopes: whoami.scopes,
46
+ channel,
47
+ operations: { available, unavailable },
48
+ };
49
+ if (whoami.app_id)
50
+ out.app_id = whoami.app_id;
51
+ if (whoami.org_id)
52
+ out.org_id = whoami.org_id;
53
+ if (whoami.app_slug)
54
+ out.app_slug = whoami.app_slug;
55
+ if (whoami.audience)
56
+ out.audience = whoami.audience;
57
+ if (whoami.api_version)
58
+ out.api_version = whoami.api_version;
59
+ if (whoami.allowed_clients)
60
+ out.allowed_clients = whoami.allowed_clients;
61
+ if (whoami.feature_flags && Object.keys(whoami.feature_flags).length > 0) {
62
+ out.feature_flags = whoami.feature_flags;
63
+ }
64
+ if (whoami.rate_limit?.qps != null)
65
+ out.rate_limit_qps = whoami.rate_limit.qps;
66
+ if (whoami.rate_limit?.monthly_quota != null) {
67
+ out.monthly_quota = whoami.rate_limit.monthly_quota;
68
+ }
69
+ return out;
70
+ }
71
+ export function buildWhoamiCommand() {
72
+ const cmd = new Command("whoami").description("Show current token capabilities (kind / scopes / available operations)");
73
+ cmd.addOption(new Option("--no-cache", "Bypass 5-minute whoami cache"));
74
+ cmd.addOption(new Option("--key <key>", "Override credential (env / config)"));
75
+ cmd.addOption(new Option("--profile <name>", "Use a specific profile"));
76
+ cmd.action(async (opts) => {
77
+ let creds;
78
+ try {
79
+ creds = resolveCredentials({
80
+ ...(opts.key ? { key: opts.key } : {}),
81
+ ...(opts.profile ? { profile: opts.profile } : {}),
82
+ });
83
+ }
84
+ catch (e) {
85
+ if (e instanceof AuthMissingError) {
86
+ emitError("auth_missing", e.message, e.recovery_hint, 1);
87
+ }
88
+ throw e;
89
+ }
90
+ if (opts.cache === false)
91
+ clearWhoamiCache();
92
+ let whoami;
93
+ try {
94
+ whoami = await getWhoami({ baseUrl: creds.baseUrl, bearer: creds.key, cliVersion: CLI_VERSION }, { force: opts.cache === false });
95
+ }
96
+ catch (e) {
97
+ if (e instanceof CallApiError) {
98
+ emitError(e.code, e.message, e.recovery_hint, e.httpStatus && e.httpStatus < 500 ? 1 : 2, e.requestId);
99
+ }
100
+ throw e;
101
+ }
102
+ const data = buildWhoamiOutput(whoami, "cli");
103
+ const envelope = {
104
+ data,
105
+ meta: {
106
+ cli_version: CLI_VERSION,
107
+ ...(whoami.api_version ? { api_version: whoami.api_version } : {}),
108
+ },
109
+ };
110
+ process.stdout.write(JSON.stringify(envelope) + "\n");
111
+ process.exit(0);
112
+ });
113
+ return cmd;
114
+ }
115
+ function emitError(code, message, recoveryHint, exitCode, requestId) {
116
+ const env = {
117
+ error: {
118
+ code,
119
+ message,
120
+ retryable: false,
121
+ ...(recoveryHint ? { recovery_hint: recoveryHint } : {}),
122
+ ...(requestId ? { request_id: requestId } : {}),
123
+ },
124
+ };
125
+ if (isTtyHuman) {
126
+ process.stderr.write(renderError(env) + "\n");
127
+ }
128
+ else {
129
+ process.stderr.write(JSON.stringify(env) + "\n");
130
+ }
131
+ process.exit(exitCode);
132
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * VerbSpec —— curated verb 的源声明,CLI 与 MCP 两条入口共享。
3
+ *
4
+ * - CLI (commander): build*Command() 内部把 spec.handler 接到 commander.action
5
+ * 外加 executeVerb (process.exit / stderr / 错误 envelope) 包装。
6
+ * - MCP: tools/mcp.ts 启动时枚举所有 spec,把 inputSchema 直接喂 SDK
7
+ * registerTool,handler 包成 MCP tool callback。
8
+ *
9
+ * 单源声明保证两个入口的 verb 名称 / 参数语义 / 输出形态完全同步。
10
+ *
11
+ * inputSchema 用 Zod raw shape (Record<string, ZodType>) —— MCP SDK 原生
12
+ * 接受;CLI 端不重复用它做校验 (commander Option + 我们 verb 内部的
13
+ * clamp/parse 已足够)。
14
+ */
15
+ export {};
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `echopai bars-batch` + MCP tool `bars_batch`.
3
+ */
4
+ import { Command, Option } from "commander";
5
+ import { z } from "zod";
6
+ import { OPERATIONS } from "../_generated/operations.js";
7
+ import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
8
+ import { callOp } from "../runtime/verb_runner.js";
9
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
10
+ const CODE_RE = /^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/;
11
+ export const barsBatchSpec = {
12
+ name: "bars_batch",
13
+ description: "Batch K-line for multiple A-share securities in one round-trip. Daily: ≤100 codes × ≤1yr; minute: ≤20 codes × ≤7d. Returns partial-success envelope {items, errors}.",
14
+ inputSchema: {
15
+ codes: z
16
+ .array(z.string().regex(CODE_RE))
17
+ .min(1)
18
+ .max(100)
19
+ .describe("Canonical codes (daily ≤100; minute ≤20)"),
20
+ from: z.string().regex(DATE_RE).describe("Inclusive start date YYYY-MM-DD"),
21
+ to: z.string().regex(DATE_RE).describe("Inclusive end date YYYY-MM-DD"),
22
+ minute: z
23
+ .boolean()
24
+ .optional()
25
+ .describe("Minute-level bars instead of daily (tighter caps apply)"),
26
+ },
27
+ handler: async (args, ctx) => {
28
+ const isMinute = Boolean(args.minute);
29
+ const cap = isMinute ? 20 : 100;
30
+ const codes = args.codes;
31
+ if (codes.length > cap) {
32
+ throw new Error(`codes count ${codes.length} exceeds cap ${cap} for ${isMinute ? "minute" : "daily"} bars_batch`);
33
+ }
34
+ const cliKey = isMinute ? "bars.minute-batch" : "bars.daily-batch";
35
+ const op = OPERATIONS[cliKey];
36
+ if (!op)
37
+ throw new Error(`${cliKey} op missing from codegen`);
38
+ return callOp(op, { codes, from: args.from, to: args.to }, ctx);
39
+ },
40
+ backingOps: ["bars.daily-batch", "bars.minute-batch"],
41
+ };
42
+ export function buildBarsBatchCommand() {
43
+ // CLI alias keeps the hyphenated form. MCP tool name uses underscore (MCP
44
+ // convention; underscores parse cleaner across hosts).
45
+ const cmd = new Command("bars-batch").description(barsBatchSpec.description);
46
+ cmd.addOption(new Option("--codes <csv>", "Canonical codes, comma-separated")
47
+ .makeOptionMandatory(true));
48
+ cmd.addOption(new Option("--from <date>", "Inclusive start YYYY-MM-DD").makeOptionMandatory(true));
49
+ cmd.addOption(new Option("--to <date>", "Inclusive end YYYY-MM-DD").makeOptionMandatory(true));
50
+ cmd.addOption(new Option("--minute", "Minute-level bars instead of daily"));
51
+ cmd.action(async (opts) => {
52
+ const codes = opts.codes.split(",").map((s) => s.trim()).filter(Boolean);
53
+ const isMinute = Boolean(opts.minute);
54
+ const cap = isMinute ? 20 : 100;
55
+ if (codes.length === 0 || codes.length > cap) {
56
+ emitVerbError("invalid_args", `codes count ${codes.length} out of range; expected 1-${cap} for ${isMinute ? "minute" : "daily"} bars-batch`, isMinute
57
+ ? "Daily mode (default) allows up to 100 codes; drop --minute or chunk the request."
58
+ : "Chunk the request into batches of ≤100 codes.", 1);
59
+ }
60
+ const args = { codes, from: opts.from, to: opts.to };
61
+ if (opts.minute)
62
+ args.minute = true;
63
+ await executeVerb(async (ctx) => barsBatchSpec.handler(args, ctx));
64
+ });
65
+ return cmd;
66
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `echopai chart` + MCP tool `chart`.
3
+ */
4
+ import { Command, Option } from "commander";
5
+ import { z } from "zod";
6
+ import { OPERATIONS } from "../_generated/operations.js";
7
+ import { executeVerb, emitVerbError } from "../runtime/verb_cmd.js";
8
+ import { callOp } from "../runtime/verb_runner.js";
9
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
10
+ export const chartSpec = {
11
+ name: "chart",
12
+ description: "Single-security K-line (daily by default; set minute=true for intraday with a single date). For multi-code use `bars_batch` instead.",
13
+ inputSchema: {
14
+ code: z
15
+ .string()
16
+ .regex(/^(SSE|SZSE|BSE|SH|SZ|BJ):[0-9]{6}$/)
17
+ .describe("Single canonical code (e.g. SSE:600519)"),
18
+ days: z
19
+ .number()
20
+ .int()
21
+ .min(1)
22
+ .max(365)
23
+ .default(30)
24
+ .describe("Daily lookback (ignored when from/to or minute mode are set)"),
25
+ from: z
26
+ .string()
27
+ .regex(DATE_RE)
28
+ .optional()
29
+ .describe("YYYY-MM-DD inclusive start (daily mode; overrides days)"),
30
+ to: z
31
+ .string()
32
+ .regex(DATE_RE)
33
+ .optional()
34
+ .describe("YYYY-MM-DD inclusive end (daily mode; overrides days)"),
35
+ minute: z.boolean().optional().describe("Switch to minute-level bars (requires date)"),
36
+ date: z.string().regex(DATE_RE).optional().describe("Single trade date YYYY-MM-DD (minute mode)"),
37
+ },
38
+ handler: async (args, ctx) => {
39
+ if (args.minute) {
40
+ if (!args.date) {
41
+ throw new Error("minute=true requires date (YYYY-MM-DD)");
42
+ }
43
+ const op = OPERATIONS["bars.minute"];
44
+ if (!op)
45
+ throw new Error("bars.minute op missing");
46
+ // bars.minute expects a single `date` (YYYY-MM-DD), not from/to —
47
+ // see stockpulse-rs MinuteBarsQuery + OpenAPI 2026-05-12 correction.
48
+ return callOp(op, { code: args.code, date: args.date }, ctx);
49
+ }
50
+ const op = OPERATIONS["bars.daily"];
51
+ if (!op)
52
+ throw new Error("bars.daily op missing");
53
+ const callArgs = { code: args.code };
54
+ if (args.from && args.to) {
55
+ callArgs.from = args.from;
56
+ callArgs.to = args.to;
57
+ }
58
+ else {
59
+ // bars.daily requires from/to (additionalProperties:false; no `days`).
60
+ // Derive a from/to window from --days client-side. Includes today;
61
+ // server skips non-trading days automatically.
62
+ const days = Math.max(1, Number(args.days ?? 30));
63
+ const to = new Date();
64
+ const from = new Date(to.getTime() - (days - 1) * 24 * 3600 * 1000);
65
+ const fmt = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
66
+ callArgs.from = fmt(from);
67
+ callArgs.to = fmt(to);
68
+ }
69
+ return callOp(op, callArgs, ctx);
70
+ },
71
+ backingOps: ["bars.daily", "bars.minute"],
72
+ };
73
+ function clamp(raw, min, max, fallback) {
74
+ const n = Math.floor(Number(raw));
75
+ if (!Number.isFinite(n))
76
+ return fallback;
77
+ if (n < min)
78
+ return min;
79
+ if (n > max)
80
+ return max;
81
+ return n;
82
+ }
83
+ export function buildChartCommand() {
84
+ const cmd = new Command(chartSpec.name).description(chartSpec.description);
85
+ cmd.addOption(new Option("--code <canonical_code>", "Single canonical code").makeOptionMandatory(true));
86
+ cmd.addOption(new Option("--days <n>", "Daily lookback (1-365, default 30)").default("30"));
87
+ cmd.addOption(new Option("--from <date>", "Inclusive start date YYYY-MM-DD (overrides --days)"));
88
+ cmd.addOption(new Option("--to <date>", "Inclusive end date YYYY-MM-DD (overrides --days)"));
89
+ cmd.addOption(new Option("--minute", "Switch to minute-level bars (requires --date)"));
90
+ cmd.addOption(new Option("--date <date>", "Single trade date YYYY-MM-DD (minute mode)"));
91
+ cmd.action(async (opts) => {
92
+ if (opts.minute && !opts.date) {
93
+ emitVerbError("invalid_args", "--minute requires --date YYYY-MM-DD (single trade date)", "Example: echopai chart --minute --code SSE:600519 --date 2026-05-09", 1);
94
+ }
95
+ const args = {
96
+ code: opts.code,
97
+ days: clamp(opts.days, 1, 365, 30),
98
+ };
99
+ if (opts.from)
100
+ args.from = opts.from;
101
+ if (opts.to)
102
+ args.to = opts.to;
103
+ if (opts.minute)
104
+ args.minute = true;
105
+ if (opts.date)
106
+ args.date = opts.date;
107
+ await executeVerb(async (ctx) => chartSpec.handler(args, ctx));
108
+ });
109
+ return cmd;
110
+ }