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,81 @@
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
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * `echopai completion <bash|zsh|fish>` 输出 shell completion 脚本。
3
+ *
4
+ * 不依赖 tabtab,自己生成(30 行 bash / 20 行 zsh / 15 行 fish)。
5
+ * 用 `echopai schema list` 实时拿命令列表。
6
+ *
7
+ * bash: echopai completion bash > ~/.local/share/bash-completion/completions/echopai
8
+ * zsh: echopai completion zsh > ~/.zsh/completions/_echopai (需要 fpath 含此目录)
9
+ * fish: echopai completion fish > ~/.config/fish/completions/echopai.fish
10
+ */
11
+ import { Command } from "commander";
12
+ import { listOperations } from "../_generated/operations.js";
13
+ export function buildCompletionCommand() {
14
+ return new Command("completion")
15
+ .argument("<shell>", "bash | zsh | fish")
16
+ .description("Print shell completion script for echopai")
17
+ .action((shell) => {
18
+ const ops = listOperations();
19
+ // groups: map noun → set of verbs
20
+ const tree = new Map();
21
+ for (const op of ops) {
22
+ const parts = op.cliName.split(" ");
23
+ const noun = parts[0];
24
+ const verb = parts[1] || "";
25
+ if (!tree.has(noun))
26
+ tree.set(noun, new Set());
27
+ if (verb)
28
+ tree.get(noun).add(verb);
29
+ }
30
+ const nouns = [...tree.keys()].sort();
31
+ // hand-curated tools
32
+ const tools = ["login", "logout", "status", "config", "api", "schema", "completion"];
33
+ const allCmds = [...nouns, ...tools].sort();
34
+ let out = "";
35
+ if (shell === "bash") {
36
+ out = bashCompletion(allCmds, tree);
37
+ }
38
+ else if (shell === "zsh") {
39
+ out = zshCompletion(allCmds, tree);
40
+ }
41
+ else if (shell === "fish") {
42
+ out = fishCompletion(allCmds, tree);
43
+ }
44
+ else {
45
+ process.stderr.write(JSON.stringify({ error: { code: "invalid_args", message: `Unsupported shell '${shell}'`, recovery_hint: "Use bash | zsh | fish." } }) + "\n");
46
+ process.exit(1);
47
+ }
48
+ process.stdout.write(out);
49
+ process.exit(0);
50
+ });
51
+ }
52
+ function bashCompletion(cmds, tree) {
53
+ const subcaseLines = [...tree.entries()]
54
+ .map(([noun, verbs]) => ` ${noun}) COMPREPLY=( $(compgen -W "${[...verbs].sort().join(" ")}" -- "$cur") );;`)
55
+ .join("\n");
56
+ return `# echopai bash completion
57
+ _echopai() {
58
+ local cur prev
59
+ cur="\${COMP_WORDS[COMP_CWORD]}"
60
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
61
+ if [ "$COMP_CWORD" = "1" ]; then
62
+ COMPREPLY=( $(compgen -W "${cmds.join(" ")}" -- "$cur") )
63
+ return
64
+ fi
65
+ case "\${COMP_WORDS[1]}" in
66
+ ${subcaseLines}
67
+ config) COMPREPLY=( $(compgen -W "show set profile" -- "$cur") );;
68
+ schema) COMPREPLY=( $(compgen -W "list get export" -- "$cur") );;
69
+ esac
70
+ }
71
+ complete -F _echopai echopai
72
+ `;
73
+ }
74
+ function zshCompletion(cmds, tree) {
75
+ const subcases = [...tree.entries()]
76
+ .map(([noun, verbs]) => ` "${noun}") _values "${noun} verb" ${[...verbs].sort().map((v) => `"${v}"`).join(" ")} ;;`)
77
+ .join("\n");
78
+ return `#compdef echopai
79
+ _echopai() {
80
+ local -a cmds
81
+ cmds=(${cmds.map((c) => `"${c}"`).join(" ")})
82
+ if (( CURRENT == 2 )); then
83
+ _values "echopai command" $cmds
84
+ return
85
+ fi
86
+ case "$words[2]" in
87
+ ${subcases}
88
+ config) _values "config sub" "show" "set" "profile" ;;
89
+ schema) _values "schema sub" "list" "get" "export" ;;
90
+ esac
91
+ }
92
+ _echopai "$@"
93
+ `;
94
+ }
95
+ function fishCompletion(cmds, tree) {
96
+ const lines = [`# echopai fish completion`];
97
+ lines.push(`complete -c echopai -f`);
98
+ for (const c of cmds) {
99
+ lines.push(`complete -c echopai -n '__fish_use_subcommand' -a "${c}"`);
100
+ }
101
+ for (const [noun, verbs] of tree.entries()) {
102
+ for (const verb of [...verbs].sort()) {
103
+ lines.push(`complete -c echopai -n "__fish_seen_subcommand_from ${noun}" -a "${verb}"`);
104
+ }
105
+ }
106
+ // hand-curated
107
+ for (const [parent, sub] of [
108
+ ["config", ["show", "set", "profile"]],
109
+ ["schema", ["list", "get", "export"]],
110
+ ]) {
111
+ for (const v of sub) {
112
+ lines.push(`complete -c echopai -n "__fish_seen_subcommand_from ${parent}" -a "${v}"`);
113
+ }
114
+ }
115
+ return lines.join("\n") + "\n";
116
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `echopai config [...]`
3
+ *
4
+ * echopai config show 显示完整 config
5
+ * echopai config set default_profile <name> 切默认 profile
6
+ *
7
+ * echopai config profile add <name> --key <eps> [--base-url <url>]
8
+ * echopai config profile rm <name>
9
+ * echopai config profile list
10
+ * echopai config profile switch <name> ≡ set default_profile
11
+ *
12
+ * 配置文件 ~/.config/echopai/config.toml(XDG)。文件 mode 0600。
13
+ */
14
+ import { Command } from "commander";
15
+ import { configPath, readConfigFile, writeConfigFile, } from "../runtime/auth.js";
16
+ export function buildConfigCommand() {
17
+ const cmd = new Command("config").description("Manage CLI config (~/.config/echopai/config.toml)");
18
+ cmd
19
+ .command("show")
20
+ .description("Print current config (key 字段会脱敏成 eps_live_***_***)")
21
+ .action(() => {
22
+ const cfg = readConfigFile();
23
+ const masked = JSON.parse(JSON.stringify(cfg));
24
+ if (masked.profiles) {
25
+ for (const p of Object.values(masked.profiles)) {
26
+ if (p && typeof p === "object" && typeof p.key === "string") {
27
+ p.key = redactKey(p.key);
28
+ }
29
+ }
30
+ }
31
+ process.stdout.write(JSON.stringify({ data: masked, meta: { path: configPath() } }, null, 2) + "\n");
32
+ process.exit(0);
33
+ });
34
+ cmd
35
+ .command("set <key> <value>")
36
+ .description("Set a top-level config key (currently only 'default_profile' is recognized)")
37
+ .action((key, value) => {
38
+ const cfg = readConfigFile();
39
+ if (key === "default_profile") {
40
+ cfg.default_profile = value;
41
+ writeConfigFile(cfg);
42
+ process.stdout.write(JSON.stringify({ data: { ok: true }, meta: { path: configPath() } }) + "\n");
43
+ process.exit(0);
44
+ }
45
+ writeError("invalid_args", `Unknown top-level config key '${key}'`, "Use one of: default_profile", 1);
46
+ });
47
+ const profile = cmd.command("profile").description("Manage credential profiles");
48
+ profile
49
+ .command("add <name>")
50
+ .description("Add or overwrite a profile")
51
+ .requiredOption("--key <eps_live_X_Y>", "Bearer credential")
52
+ .option("--base-url <url>", "API base URL", "https://api.echopai.com")
53
+ .action((name, opts) => {
54
+ const cfg = readConfigFile();
55
+ cfg.profiles = cfg.profiles ?? {};
56
+ cfg.profiles[name] = { key: opts.key, base_url: opts.baseUrl };
57
+ if (!cfg.default_profile)
58
+ cfg.default_profile = name;
59
+ writeConfigFile(cfg);
60
+ process.stdout.write(JSON.stringify({ data: { ok: true, name, default: cfg.default_profile === name }, meta: { path: configPath() } }) + "\n");
61
+ process.exit(0);
62
+ });
63
+ profile
64
+ .command("rm <name>")
65
+ .description("Remove a profile")
66
+ .action((name) => {
67
+ const cfg = readConfigFile();
68
+ if (!cfg.profiles?.[name]) {
69
+ writeError("profile_not_found", `Profile '${name}' not in config.`, undefined, 1);
70
+ }
71
+ delete cfg.profiles[name];
72
+ if (cfg.default_profile === name)
73
+ cfg.default_profile = undefined;
74
+ writeConfigFile(cfg);
75
+ process.stdout.write(JSON.stringify({ data: { ok: true, removed: name }, meta: { path: configPath() } }) + "\n");
76
+ process.exit(0);
77
+ });
78
+ profile
79
+ .command("list")
80
+ .description("List all profiles (keys redacted)")
81
+ .action(() => {
82
+ const cfg = readConfigFile();
83
+ const items = Object.entries(cfg.profiles ?? {}).map(([name, p]) => ({
84
+ name,
85
+ is_default: cfg.default_profile === name,
86
+ base_url: p?.base_url ?? "https://api.echopai.com",
87
+ key_redacted: p?.key ? redactKey(p.key) : null,
88
+ }));
89
+ process.stdout.write(JSON.stringify({ data: items, meta: { path: configPath() } }) + "\n");
90
+ process.exit(0);
91
+ });
92
+ profile
93
+ .command("switch <name>")
94
+ .description("Set default_profile")
95
+ .action((name) => {
96
+ const cfg = readConfigFile();
97
+ if (!cfg.profiles?.[name]) {
98
+ writeError("profile_not_found", `Profile '${name}' not in config.`, "Run `echopai config profile list` to see available.", 1);
99
+ }
100
+ cfg.default_profile = name;
101
+ writeConfigFile(cfg);
102
+ process.stdout.write(JSON.stringify({ data: { ok: true, default_profile: name } }) + "\n");
103
+ process.exit(0);
104
+ });
105
+ return cmd;
106
+ }
107
+ export function redactKey(key) {
108
+ // eps_live_<lookup>_<secret> → eps_live_<lookup>_*** (last 4 chars)
109
+ if (!key.startsWith("eps_live_"))
110
+ return "eps_***";
111
+ const parts = key.split("_");
112
+ if (parts.length < 4)
113
+ return "eps_live_***";
114
+ const last = parts.slice(3).join("_");
115
+ return `eps_live_${parts[2]}_***${last.slice(-4)}`;
116
+ }
117
+ function writeError(code, message, recovery_hint, exitCode) {
118
+ const env = { error: { code, message } };
119
+ if (recovery_hint)
120
+ env.error.recovery_hint = recovery_hint;
121
+ process.stderr.write(JSON.stringify(env) + "\n");
122
+ process.exit(exitCode);
123
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * `echopai doctor`
3
+ *
4
+ * 跑一组健康检查,每条 ok | warn | fail。所有 ok → exit 0;存在 fail → exit 1。
5
+ *
6
+ * 当前覆盖(Phase 2.4 起步集):
7
+ * credential 本地 token / profile 能解析
8
+ * whoami /v1/auth/whoami 可达且 200
9
+ * operations token 至少能调到一条 operation
10
+ * api_version CLI 期望 api_version 与 server 报告兼容
11
+ *
12
+ * 未来扩展:clock_skew (Date header) / ws_news (1-frame 探测) / scope_coverage
13
+ * (verb tier 满足度)。
14
+ */
15
+ import { Command, Option } from "commander";
16
+ import { listOperations } from "../_generated/operations.js";
17
+ import { resolveCredentials, AuthMissingError } from "../runtime/auth.js";
18
+ import { CallApiError } from "../runtime/errors.js";
19
+ import { isTtyHuman, cyan, dim, green, red, yellow } from "../runtime/tty.js";
20
+ import { clearWhoamiCache, getWhoami } from "../runtime/whoami_cache.js";
21
+ import { deriveOperationAvailability } from "./whoami.js";
22
+ import { CLI_VERSION } from "../version.js";
23
+ const EXPECTED_API_VERSION = "2026-05"; // bump when CLI hard-depends on server change
24
+ export async function runDoctor(ctx) {
25
+ const checks = [];
26
+ const resolveFn = ctx.resolveCredentialsImpl ?? resolveCredentials;
27
+ const whoamiFn = ctx.getWhoamiImpl ?? getWhoami;
28
+ // 1. credential resolution
29
+ let creds = null;
30
+ try {
31
+ creds = resolveFn({});
32
+ checks.push({ name: "credential", status: "ok" });
33
+ }
34
+ catch (e) {
35
+ if (e instanceof AuthMissingError) {
36
+ checks.push({
37
+ name: "credential",
38
+ status: "fail",
39
+ message: e.message,
40
+ recovery_hint: e.recovery_hint ?? "Run `echopai login` or set `ECHOPAI_KEY`.",
41
+ });
42
+ }
43
+ else {
44
+ checks.push({
45
+ name: "credential",
46
+ status: "fail",
47
+ message: e instanceof Error ? e.message : String(e),
48
+ });
49
+ }
50
+ }
51
+ if (!creds) {
52
+ // Subsequent checks all require a credential.
53
+ for (const name of ["whoami", "operations", "api_version"]) {
54
+ checks.push({
55
+ name,
56
+ status: "fail",
57
+ message: "skipped: credential unavailable",
58
+ });
59
+ }
60
+ return { ok: false, cli_version: ctx.cliVersion, checks };
61
+ }
62
+ // 2. whoami reachable
63
+ let whoami;
64
+ try {
65
+ whoami = await whoamiFn({ baseUrl: creds.baseUrl, bearer: creds.key, cliVersion: ctx.cliVersion }, { force: true });
66
+ checks.push({
67
+ name: "whoami",
68
+ status: "ok",
69
+ info: {
70
+ kind: whoami.kind,
71
+ scope_count: whoami.scopes.length,
72
+ ...(whoami.app_slug ? { app_slug: whoami.app_slug } : {}),
73
+ },
74
+ });
75
+ }
76
+ catch (e) {
77
+ const code = e instanceof CallApiError ? e.code : "internal_error";
78
+ const msg = e instanceof Error ? e.message : String(e);
79
+ const hint = e instanceof CallApiError ? e.recovery_hint : undefined;
80
+ checks.push({
81
+ name: "whoami",
82
+ status: "fail",
83
+ message: `${code}: ${msg}`,
84
+ ...(hint ? { recovery_hint: hint } : {}),
85
+ });
86
+ // Without whoami, downstream checks are meaningless.
87
+ for (const name of ["operations", "api_version"]) {
88
+ checks.push({
89
+ name,
90
+ status: "fail",
91
+ message: "skipped: whoami failed",
92
+ });
93
+ }
94
+ return { ok: false, cli_version: ctx.cliVersion, checks };
95
+ }
96
+ // 3. operations: at least one usable op
97
+ const { available, unavailable } = deriveOperationAvailability(new Set(whoami.scopes), listOperations());
98
+ if (available.length === 0) {
99
+ checks.push({
100
+ name: "operations",
101
+ status: "fail",
102
+ message: `Token scopes ${JSON.stringify(whoami.scopes)} grant access to 0 operations.`,
103
+ recovery_hint: "Request additional scopes (e.g. news:read / quote:l1) from your app admin.",
104
+ info: { available: 0, unavailable: unavailable.length },
105
+ });
106
+ }
107
+ else {
108
+ checks.push({
109
+ name: "operations",
110
+ status: "ok",
111
+ info: { available: available.length, unavailable: unavailable.length },
112
+ });
113
+ }
114
+ // 4. api_version compatibility
115
+ const serverVersion = whoami.api_version;
116
+ if (!serverVersion) {
117
+ checks.push({
118
+ name: "api_version",
119
+ status: "warn",
120
+ message: "Server did not report api_version (older deployment).",
121
+ });
122
+ }
123
+ else if (serverVersion !== EXPECTED_API_VERSION) {
124
+ checks.push({
125
+ name: "api_version",
126
+ status: "warn",
127
+ message: `CLI expects ${EXPECTED_API_VERSION}, server reports ${serverVersion}.`,
128
+ recovery_hint: "Usually safe (additive changes). Upgrade CLI if you hit unexpected errors.",
129
+ info: { cli_expected: EXPECTED_API_VERSION, server: serverVersion },
130
+ });
131
+ }
132
+ else {
133
+ checks.push({
134
+ name: "api_version",
135
+ status: "ok",
136
+ info: { version: serverVersion },
137
+ });
138
+ }
139
+ const ok = checks.every((c) => c.status !== "fail");
140
+ return { ok, cli_version: ctx.cliVersion, checks };
141
+ }
142
+ export function renderDoctorReport(report) {
143
+ if (!isTtyHuman)
144
+ return JSON.stringify({ data: report }) + "\n";
145
+ const lines = [];
146
+ lines.push(`${cyan("echopai doctor")} ${dim("cli " + report.cli_version)}`);
147
+ lines.push("");
148
+ for (const c of report.checks) {
149
+ const icon = c.status === "ok" ? green("✓") : c.status === "warn" ? yellow("!") : red("✗");
150
+ const head = ` ${icon} ${c.name}`;
151
+ if (c.message) {
152
+ lines.push(`${head} ${dim("—")} ${c.message}`);
153
+ }
154
+ else if (c.info) {
155
+ lines.push(`${head} ${dim(JSON.stringify(c.info))}`);
156
+ }
157
+ else {
158
+ lines.push(head);
159
+ }
160
+ if (c.recovery_hint)
161
+ lines.push(` ${cyan("hint:")} ${c.recovery_hint}`);
162
+ }
163
+ lines.push("");
164
+ lines.push(report.ok ? green("All checks passed.") : red("One or more checks failed."));
165
+ return lines.join("\n") + "\n";
166
+ }
167
+ export function buildDoctorCommand() {
168
+ const cmd = new Command("doctor").description("Diagnose CLI environment, credential, server reachability, and capability");
169
+ cmd.addOption(new Option("--no-cache", "Bypass whoami cache for fresh check"));
170
+ cmd.action(async (opts) => {
171
+ if (opts.cache === false)
172
+ clearWhoamiCache();
173
+ const report = await runDoctor({ cliVersion: CLI_VERSION });
174
+ if (isTtyHuman) {
175
+ process.stdout.write(renderDoctorReport(report));
176
+ }
177
+ else {
178
+ process.stdout.write(JSON.stringify({ data: report }) + "\n");
179
+ }
180
+ process.exit(report.ok ? 0 : 1);
181
+ });
182
+ return cmd;
183
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `echopai login --key <eps_live_X_Y> [--profile <name>] [--base-url <url>]`
3
+ * `echopai logout [--profile <name>]`
4
+ * `echopai status [--profile <name>]`
5
+ *
6
+ * login 把 key 写入 config profile(默认 'default')。不在 login 时 round-trip
7
+ * 验证 key —— 第一次实际调用会自然报 auth_invalid。
8
+ *
9
+ * status 显示当前 profile / base_url / 脱敏 key prefix。
10
+ */
11
+ import { Command } from "commander";
12
+ import { configPath, readConfigFile, resolveCredentials, writeConfigFile, AuthMissingError, } from "../runtime/auth.js";
13
+ import { redactKey } from "./config.js";
14
+ export function buildLoginCommand() {
15
+ return new Command("login")
16
+ .description("Save Bearer credential to config profile")
17
+ .requiredOption("--key <eps_live_X_Y>", "Bearer credential issued by EchoPai admin console")
18
+ .option("--profile <name>", "Profile to save under", "default")
19
+ .option("--base-url <url>", "API base URL", "https://api.echopai.com")
20
+ .action((opts) => {
21
+ const cfg = readConfigFile();
22
+ cfg.profiles = cfg.profiles ?? {};
23
+ cfg.profiles[opts.profile] = { key: opts.key, base_url: opts.baseUrl };
24
+ if (!cfg.default_profile)
25
+ cfg.default_profile = opts.profile;
26
+ writeConfigFile(cfg);
27
+ process.stdout.write(JSON.stringify({
28
+ data: {
29
+ ok: true,
30
+ profile: opts.profile,
31
+ base_url: opts.baseUrl,
32
+ key_redacted: redactKey(opts.key),
33
+ default: cfg.default_profile === opts.profile,
34
+ },
35
+ meta: { path: configPath() },
36
+ }) + "\n");
37
+ process.exit(0);
38
+ });
39
+ }
40
+ export function buildLogoutCommand() {
41
+ return new Command("logout")
42
+ .description("Remove Bearer credential from config profile")
43
+ .option("--profile <name>", "Profile to clear (default = default_profile)")
44
+ .action((opts) => {
45
+ const cfg = readConfigFile();
46
+ const target = opts.profile || cfg.default_profile;
47
+ if (!target || !cfg.profiles?.[target]) {
48
+ process.stderr.write(JSON.stringify({
49
+ error: {
50
+ code: "profile_not_found",
51
+ message: `No profile '${target ?? "(none)"}' configured.`,
52
+ },
53
+ }) + "\n");
54
+ process.exit(1);
55
+ }
56
+ delete cfg.profiles[target];
57
+ if (cfg.default_profile === target)
58
+ cfg.default_profile = undefined;
59
+ writeConfigFile(cfg);
60
+ process.stdout.write(JSON.stringify({ data: { ok: true, removed_profile: target }, meta: { path: configPath() } }) + "\n");
61
+ process.exit(0);
62
+ });
63
+ }
64
+ export function buildStatusCommand() {
65
+ return new Command("status")
66
+ .description("Show current credential / profile / base URL (key redacted)")
67
+ .option("--profile <name>", "Profile to inspect")
68
+ .action((opts) => {
69
+ try {
70
+ const resolveOpts = {};
71
+ if (opts.profile)
72
+ resolveOpts.profile = opts.profile;
73
+ const c = resolveCredentials(resolveOpts);
74
+ process.stdout.write(JSON.stringify({
75
+ data: {
76
+ source: process.env.ECHOPAI_KEY && !opts.profile
77
+ ? "env"
78
+ : c.profile
79
+ ? "config"
80
+ : "env",
81
+ profile: c.profile,
82
+ base_url: c.baseUrl,
83
+ key_redacted: redactKey(c.key),
84
+ },
85
+ meta: { config_path: configPath() },
86
+ }) + "\n");
87
+ process.exit(0);
88
+ }
89
+ catch (e) {
90
+ if (e instanceof AuthMissingError) {
91
+ process.stderr.write(JSON.stringify({
92
+ error: { code: "auth_missing", message: e.message, recovery_hint: e.recovery_hint },
93
+ }) + "\n");
94
+ process.exit(1);
95
+ }
96
+ throw e;
97
+ }
98
+ });
99
+ }