@evatick/cli 0.3.2 → 0.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 CHANGED
@@ -16,13 +16,11 @@ It can also be run without a global installation:
16
16
  npx @evatick/cli health
17
17
  ```
18
18
 
19
- The npm package installs a native executable for the current platform. Python is
20
- not required. Supported targets are macOS arm64/x64, Linux x64 (glibc), and
21
- Windows x64.
19
+ The npm package is implemented in Node.js and has no runtime dependencies.
20
+ Python and platform-specific native executables are not required.
22
21
 
23
22
  JSON, JSONL, and CSV exports are supported. Parquet export remains available in
24
- the Python distribution (`pip install 'evatick[parquet]'`) and is intentionally
25
- not bundled into the native npm executables.
23
+ the Python distribution (`pip install 'evatick[parquet]'`).
26
24
 
27
25
  See the [EVA CLI repository](https://github.com/xiaochaohit/evatick-cli) for
28
26
  commands, configuration, source code, and license information.
package/bin/eva.js CHANGED
@@ -2,42 +2,21 @@
2
2
 
3
3
  "use strict";
4
4
 
5
- const { spawnSync } = require("node:child_process");
5
+ const { main } = require("../lib/cli.js");
6
6
 
7
- const targets = {
8
- "darwin-arm64": ["@evatick/cli-darwin-arm64", "eva"],
9
- "darwin-x64": ["@evatick/cli-darwin-x64", "eva"],
10
- "linux-x64": ["@evatick/cli-linux-x64", "eva"],
11
- "win32-x64": ["@evatick/cli-win32-x64", "eva.exe"],
12
- };
13
-
14
- const target = `${process.platform}-${process.arch}`;
15
- const selected = targets[target];
16
- if (!selected) {
17
- console.error(
18
- `EVA CLI does not support ${target}. Supported targets: ${Object.keys(targets).join(", ")}.`,
19
- );
20
- process.exit(1);
21
- }
22
-
23
- let executable;
24
- try {
25
- executable = require.resolve(`${selected[0]}/bin/${selected[1]}`);
26
- } catch (error) {
27
- console.error(
28
- `The EVA CLI executable for ${target} is missing. Reinstall @evatick/cli without omitting optional dependencies.`,
29
- );
30
- process.exit(1);
31
- }
32
-
33
- const result = spawnSync(executable, process.argv.slice(2), {
34
- stdio: "inherit",
7
+ main(process.argv.slice(2)).catch((error) => {
8
+ const problem = error && error.evaProblem
9
+ ? error.evaProblem
10
+ : {
11
+ code: "INTERNAL_ERROR",
12
+ message: "EVA CLI failed unexpectedly",
13
+ retryable: false,
14
+ exitCode: 1,
15
+ };
16
+ process.stderr.write(`${JSON.stringify({
17
+ code: problem.code,
18
+ message: problem.message,
19
+ retryable: problem.retryable,
20
+ })}\n`);
21
+ process.exitCode = problem.exitCode;
35
22
  });
36
- if (result.error) {
37
- console.error(`Unable to start EVA CLI: ${result.error.message}`);
38
- process.exit(1);
39
- }
40
- if (result.signal) {
41
- process.kill(process.pid, result.signal);
42
- }
43
- process.exit(result.status === null ? 1 : result.status);
package/lib/cli.js ADDED
@@ -0,0 +1,414 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const {
5
+ defaultConfigurationPath,
6
+ expandHome,
7
+ loadConfiguration,
8
+ saveConfiguration,
9
+ } = require("./config.js");
10
+ const { EvaError, usage } = require("./errors.js");
11
+ const { EvaHttpClient } = require("./http-client.js");
12
+ const { dumps, emit } = require("./output.js");
13
+
14
+ const DEFAULT_SERVER_URL = "https://api.evatick.com";
15
+ const VERSION = require("../package.json").version;
16
+ const INTERVALS = ["1m", "5m", "15m", "30m", "60m", "1d", "1w", "1mo"];
17
+
18
+ const ROOT_HELP = `NAME
19
+ eva
20
+
21
+ PURPOSE
22
+ 通过 EVA 查询股票和指数数据。
23
+
24
+ USAGE
25
+ eva [--config PATH] [--server-url URL] [--api-key KEY] COMMAND [OPTIONS]
26
+
27
+ OPTIONS
28
+ --config PATH
29
+ --server-url URL
30
+ --api-key KEY
31
+
32
+ COMMANDS
33
+ config
34
+ health
35
+ index
36
+ instrument
37
+ stock
38
+ version
39
+ `;
40
+
41
+ const HELP = {
42
+ config: "Usage: eva config [OPTIONS] COMMAND [ARGS]...\n\n Manage the unified CLI configuration file.\n\nOptions:\n --help Show this message and exit.\n\nCommands:\n set\n show\n",
43
+ "config set": "Usage: eva config set [OPTIONS]\n\nOptions:\n --base-url TEXT\n --help\n",
44
+ "config show": "Usage: eva config show [OPTIONS]\n\nOptions:\n --help\n",
45
+ health: "Usage: eva health [OPTIONS]\n\nOptions:\n --timeout FLOAT [default: 10]\n --retries INTEGER [default: 0]\n --help\n",
46
+ version: "Usage: eva version [OPTIONS]\n\nOptions:\n --help\n",
47
+ instrument: "Usage: eva instrument [OPTIONS] COMMAND [ARGS]...\n\n Discover and resolve canonical instruments.\n\nOptions:\n --help Show this message and exit.\n\nCommands:\n list\n resolve\n search\n show\n",
48
+ "instrument list": "Usage: eva instrument list [OPTIONS]\n\nOptions:\n --type [equity|index]\n --venue TEXT\n --publisher TEXT\n --capability [quote|bars|constituents]\n --cursor TEXT\n --limit INTEGER\n --output PATH\n --format [json|jsonl|csv|parquet]\n --overwrite\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
49
+ "instrument search": "Usage: eva instrument search [OPTIONS]\n\nOptions:\n --query TEXT [required]\n --type [equity|index]\n --venue TEXT\n --publisher TEXT\n --capability [quote|bars|constituents]\n --limit INTEGER\n --output PATH\n --format [json|jsonl|csv|parquet]\n --overwrite\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
50
+ "instrument resolve": "Usage: eva instrument resolve [OPTIONS]\n\nOptions:\n --query TEXT [required]\n --type [equity|index]\n --venue TEXT\n --publisher TEXT\n --capability [quote|bars|constituents]\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
51
+ "instrument show": "Usage: eva instrument show [OPTIONS]\n\nOptions:\n --id TEXT [required]\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
52
+ stock: "Usage: eva stock [OPTIONS] COMMAND [ARGS]...\n\n Query mainland A-share data.\n\nOptions:\n --help Show this message and exit.\n\nCommands:\n bars\n quotes\n",
53
+ index: "Usage: eva index [OPTIONS] COMMAND [ARGS]...\n\n Query SSE, SZSE, and CSI index data.\n\nOptions:\n --help Show this message and exit.\n\nCommands:\n bars\n constituents\n quotes\n",
54
+ "stock quotes": "Usage: eva stock quotes [OPTIONS]\n\nOptions:\n --symbol TEXT [required]\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
55
+ "index quotes": "Usage: eva index quotes [OPTIONS]\n\nOptions:\n --symbol TEXT [required]\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
56
+ "stock bars": "Usage: eva stock bars [OPTIONS]\n\nOptions:\n --symbol TEXT [required]\n --interval [1m|5m|15m|30m|60m|1d|1w|1mo] [default: 1d]\n --start TEXT\n --end TEXT\n --adjustment [none|forward|backward] [default: none]\n --limit INTEGER\n --output PATH\n --format [json|jsonl|csv|parquet]\n --overwrite\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
57
+ "index bars": "Usage: eva index bars [OPTIONS]\n\nOptions:\n --symbol TEXT [required]\n --interval [1m|5m|15m|30m|60m|1d|1w|1mo] [default: 1d]\n --start TEXT\n --end TEXT\n --adjustment [none|forward|backward] [default: none]\n --limit INTEGER\n --output PATH\n --format [json|jsonl|csv|parquet]\n --overwrite\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
58
+ "index constituents": "Usage: eva index constituents [OPTIONS]\n\nOptions:\n --symbol TEXT [required]\n --as-of TEXT\n --limit INTEGER\n --output PATH\n --format [json|jsonl|csv|parquet]\n --overwrite\n --timeout FLOAT [default: 120]\n --retries INTEGER [default: 2]\n --help\n",
59
+ };
60
+
61
+ const rootDefinitions = {
62
+ config: { type: "string" },
63
+ "server-url": { type: "string" },
64
+ "api-key": { type: "string" },
65
+ };
66
+ const requestDefinitions = {
67
+ timeout: { type: "number", min: 0.1, default: 120 },
68
+ retries: { type: "integer", min: 0, default: 2 },
69
+ };
70
+ const dataDefinitions = {
71
+ limit: { type: "integer", min: 1 },
72
+ output: { type: "string" },
73
+ format: { type: "choice", choices: ["json", "jsonl", "csv", "parquet"] },
74
+ overwrite: { type: "boolean", default: false },
75
+ ...requestDefinitions,
76
+ };
77
+ const discoveryDefinitions = {
78
+ type: { type: "choice", choices: ["equity", "index"] },
79
+ venue: { type: "string" },
80
+ publisher: { type: "string" },
81
+ capability: { type: "choice", choices: ["quote", "bars", "constituents"] },
82
+ };
83
+
84
+ function parseValue(name, raw, definition) {
85
+ if (definition.type === "string") return raw;
86
+ if (definition.type === "choice") {
87
+ if (!definition.choices.includes(raw)) {
88
+ throw usage(`Invalid value for '--${name}': '${raw}' is not one of ${definition.choices.join(", ")}.`);
89
+ }
90
+ return raw;
91
+ }
92
+ const value = Number(raw);
93
+ const valid = definition.type === "integer" ? Number.isInteger(value) : Number.isFinite(value);
94
+ if (!valid || value < definition.min) {
95
+ throw usage(`Invalid value for '--${name}': ${raw}`);
96
+ }
97
+ return value;
98
+ }
99
+
100
+ function parseOptions(arguments_, definitions, required = []) {
101
+ const values = {};
102
+ for (const [name, definition] of Object.entries(definitions)) {
103
+ if (definition.default !== undefined) values[toProperty(name)] = definition.default;
104
+ }
105
+ for (let index = 0; index < arguments_.length; index += 1) {
106
+ const argument = arguments_[index];
107
+ if (!argument.startsWith("--")) throw usage(`Got unexpected extra argument (${argument})`);
108
+ const separator = argument.indexOf("=");
109
+ const name = argument.slice(2, separator === -1 ? undefined : separator);
110
+ const definition = definitions[name];
111
+ if (!definition) throw usage(`No such option: --${name}`);
112
+ const property = toProperty(name);
113
+ if (definition.type === "boolean") {
114
+ if (separator !== -1) throw usage(`Option '--${name}' does not take a value.`);
115
+ values[property] = true;
116
+ continue;
117
+ }
118
+ let raw;
119
+ if (separator !== -1) raw = argument.slice(separator + 1);
120
+ else {
121
+ index += 1;
122
+ raw = arguments_[index];
123
+ if (raw === undefined || raw.startsWith("--")) throw usage(`Option '--${name}' requires an argument.`);
124
+ }
125
+ values[property] = parseValue(name, raw, definition);
126
+ }
127
+ for (const name of required) {
128
+ if (values[toProperty(name)] === undefined) throw usage(`Missing option '--${name}'.`);
129
+ }
130
+ return values;
131
+ }
132
+
133
+ function toProperty(name) {
134
+ return name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
135
+ }
136
+
137
+ function parseRoot(arguments_) {
138
+ const optionArguments = [];
139
+ let index = 0;
140
+ while (index < arguments_.length && arguments_[index].startsWith("--")) {
141
+ const argument = arguments_[index];
142
+ if (argument === "--help") return { help: true };
143
+ optionArguments.push(argument);
144
+ const name = argument.slice(2).split("=", 1)[0];
145
+ if (!rootDefinitions[name]) throw usage(`No such option: --${name}`);
146
+ if (!argument.includes("=")) {
147
+ index += 1;
148
+ if (arguments_[index] === undefined) throw usage(`Option '--${name}' requires an argument.`);
149
+ optionArguments.push(arguments_[index]);
150
+ }
151
+ index += 1;
152
+ }
153
+ return {
154
+ options: parseOptions(optionArguments, rootDefinitions),
155
+ remaining: arguments_.slice(index),
156
+ };
157
+ }
158
+
159
+ function queryString(values) {
160
+ const query = new URLSearchParams();
161
+ for (const [name, value] of Object.entries(values)) {
162
+ if (value !== undefined && value !== null) query.set(name, String(value));
163
+ }
164
+ return query.toString();
165
+ }
166
+
167
+ function leaf(pathParts, arguments_) {
168
+ const key = pathParts.join(" ");
169
+ if (arguments_.includes("--help")) {
170
+ process.stdout.write(HELP[key]);
171
+ return null;
172
+ }
173
+ return key;
174
+ }
175
+
176
+ function contextFrom(rootOptions, environment) {
177
+ const configurationPath = path.resolve(expandHome(
178
+ rootOptions.config || defaultConfigurationPath(environment),
179
+ ));
180
+ const configuration = loadConfiguration(configurationPath);
181
+ return {
182
+ configurationPath,
183
+ configuration,
184
+ baseUrl: rootOptions.serverUrl || environment.EVA_SERVER_URL || configuration.baseUrl || DEFAULT_SERVER_URL,
185
+ apiKey: rootOptions.apiKey || environment.EVA_API_KEY || configuration.apiKey,
186
+ };
187
+ }
188
+
189
+ function client(context, options) {
190
+ return new EvaHttpClient(
191
+ context.baseUrl,
192
+ options.timeout,
193
+ options.retries,
194
+ context.apiKey,
195
+ VERSION,
196
+ );
197
+ }
198
+
199
+ async function dispatch(context, command, subcommand, arguments_) {
200
+ const key = leaf(subcommand ? [command, subcommand] : [command], arguments_);
201
+ if (key === null) return;
202
+
203
+ if (key === "version") {
204
+ parseOptions(arguments_, {});
205
+ process.stdout.write(`${dumps({
206
+ eva_cli: VERSION,
207
+ eva_server_api: "v1",
208
+ node: process.versions.node,
209
+ })}\n`);
210
+ return;
211
+ }
212
+ if (key === "config show") {
213
+ parseOptions(arguments_, {});
214
+ process.stdout.write(`${dumps({
215
+ path: context.configurationPath,
216
+ base_url: context.configuration.baseUrl || null,
217
+ api_key: context.configuration.apiKey ? "********" : null,
218
+ })}\n`);
219
+ return;
220
+ }
221
+ if (key === "config set") {
222
+ const options = parseOptions(arguments_, {
223
+ "base-url": { type: "string" },
224
+ "api-key": { type: "string" },
225
+ });
226
+ if (options.baseUrl === undefined && options.apiKey === undefined) {
227
+ throw usage("at least one of --base-url or --api-key is required");
228
+ }
229
+ const updated = {
230
+ baseUrl: options.baseUrl === undefined ? context.configuration.baseUrl : options.baseUrl,
231
+ apiKey: options.apiKey === undefined ? context.configuration.apiKey : options.apiKey,
232
+ };
233
+ if (updated.baseUrl && !(updated.baseUrl.startsWith("http://") || updated.baseUrl.startsWith("https://"))) {
234
+ throw usage("--base-url must start with http:// or https://");
235
+ }
236
+ saveConfiguration(context.configurationPath, updated);
237
+ process.stdout.write(`${dumps({ path: context.configurationPath, updated: true })}\n`);
238
+ return;
239
+ }
240
+ if (key === "health") {
241
+ const options = parseOptions(arguments_, {
242
+ timeout: { type: "number", min: 0.1, default: 10 },
243
+ retries: { type: "integer", min: 0, default: 0 },
244
+ });
245
+ const response = await client(context, options).request("GET", "/v1/health");
246
+ process.stdout.write(`${dumps(response.data ?? {})}\n`);
247
+ return;
248
+ }
249
+ if (key === "instrument list") {
250
+ const options = parseOptions(arguments_, {
251
+ ...discoveryDefinitions,
252
+ cursor: { type: "string" },
253
+ ...dataDefinitions,
254
+ });
255
+ const limit = options.limit || 100;
256
+ const response = await client(context, options).request(
257
+ "GET",
258
+ `/v1/instruments?${queryString({
259
+ instrument_type: options.type,
260
+ venue: options.venue,
261
+ publisher: options.publisher,
262
+ capability: options.capability,
263
+ cursor: options.cursor,
264
+ limit: Math.min(limit, 1000),
265
+ })}`,
266
+ );
267
+ emit(
268
+ { items: response.data ?? [], next_cursor: (response.page || {}).next_cursor ?? null },
269
+ { ...options, limit: undefined },
270
+ );
271
+ return;
272
+ }
273
+ if (key === "instrument search") {
274
+ const options = parseOptions(arguments_, {
275
+ query: { type: "string" }, ...discoveryDefinitions, ...dataDefinitions,
276
+ }, ["query"]);
277
+ const limit = Math.min(options.limit || 20, 200);
278
+ const response = await client(context, options).request(
279
+ "GET",
280
+ `/v1/instrument-search?${queryString({
281
+ q: options.query,
282
+ instrument_type: options.type,
283
+ venue: options.venue,
284
+ publisher: options.publisher,
285
+ capability: options.capability,
286
+ limit,
287
+ })}`,
288
+ );
289
+ emit(response.data ?? [], { ...options, limit });
290
+ return;
291
+ }
292
+ if (key === "instrument resolve") {
293
+ const options = parseOptions(arguments_, {
294
+ query: { type: "string" }, ...discoveryDefinitions, ...requestDefinitions,
295
+ }, ["query"]);
296
+ const resolutionContext = {};
297
+ if (options.type !== undefined) resolutionContext.instrument_type = options.type;
298
+ if (options.venue !== undefined) resolutionContext.venue = options.venue;
299
+ if (options.publisher !== undefined) resolutionContext.publisher = options.publisher;
300
+ if (options.capability !== undefined) resolutionContext.capability = options.capability;
301
+ const response = await client(context, options).request("POST", "/v1/instrument-resolve", {
302
+ query: options.query,
303
+ context: resolutionContext,
304
+ });
305
+ process.stdout.write(`${dumps(response.data ?? {})}\n`);
306
+ return;
307
+ }
308
+ if (key === "instrument show") {
309
+ const options = parseOptions(arguments_, {
310
+ id: { type: "string" }, ...requestDefinitions,
311
+ }, ["id"]);
312
+ const response = await client(context, options).request(
313
+ "GET",
314
+ `/v1/instruments/${encodeURIComponent(options.id)}`,
315
+ );
316
+ process.stdout.write(`${dumps(response.data ?? {})}\n`);
317
+ return;
318
+ }
319
+ if (key === "stock quotes" || key === "index quotes") {
320
+ const options = parseOptions(arguments_, {
321
+ symbol: { type: "string" }, ...requestDefinitions,
322
+ }, ["symbol"]);
323
+ const instrumentType = command === "stock" ? "equity" : "index";
324
+ const http = client(context, options);
325
+ const instrumentId = await http.resolve(options.symbol, instrumentType, "quote");
326
+ const response = await http.request(
327
+ "GET",
328
+ `/v1/instruments/${encodeURIComponent(instrumentId)}/quote`,
329
+ );
330
+ process.stdout.write(`${dumps(response.data ?? {})}\n`);
331
+ return;
332
+ }
333
+ if (key === "stock bars" || key === "index bars") {
334
+ const options = parseOptions(arguments_, {
335
+ symbol: { type: "string" },
336
+ interval: { type: "choice", choices: INTERVALS, default: "1d" },
337
+ start: { type: "string" },
338
+ end: { type: "string" },
339
+ adjustment: { type: "choice", choices: ["none", "forward", "backward"], default: "none" },
340
+ ...dataDefinitions,
341
+ }, ["symbol"]);
342
+ const instrumentType = command === "stock" ? "equity" : "index";
343
+ const http = client(context, options);
344
+ const instrumentId = await http.resolve(options.symbol, instrumentType, "bars");
345
+ const response = await http.request(
346
+ "GET",
347
+ `/v1/instruments/${encodeURIComponent(instrumentId)}/bars?${queryString({
348
+ interval: options.interval,
349
+ start: options.start,
350
+ end: options.end,
351
+ adjustment: options.adjustment,
352
+ })}`,
353
+ );
354
+ emit(response.data ?? [], options);
355
+ return;
356
+ }
357
+ if (key === "index constituents") {
358
+ const options = parseOptions(arguments_, {
359
+ symbol: { type: "string" }, "as-of": { type: "string" }, ...dataDefinitions,
360
+ }, ["symbol"]);
361
+ const http = client(context, options);
362
+ const instrumentId = await http.resolve(options.symbol, "index", "constituents");
363
+ const suffix = options.asOf ? `?${queryString({ as_of: options.asOf })}` : "";
364
+ const response = await http.request(
365
+ "GET",
366
+ `/v1/indices/${encodeURIComponent(instrumentId)}/constituents${suffix}`,
367
+ );
368
+ emit(response.data ?? [], options);
369
+ return;
370
+ }
371
+ throw usage(`No such command '${subcommand || command}'.`);
372
+ }
373
+
374
+ function commandParts(remaining) {
375
+ const command = remaining[0];
376
+ if (!command) return {};
377
+ if (!["config", "health", "version", "instrument", "stock", "index"].includes(command)) {
378
+ throw usage(`No such command '${command}'.`);
379
+ }
380
+ if (["health", "version"].includes(command)) {
381
+ return { command, arguments_: remaining.slice(1) };
382
+ }
383
+ const subcommand = remaining[1];
384
+ if (!subcommand) return { command, groupMissing: true };
385
+ if (subcommand === "--help") return { command, groupHelp: true };
386
+ if (subcommand.startsWith("--")) throw usage(`No such option: ${subcommand}`);
387
+ const valid = {
388
+ config: ["set", "show"],
389
+ instrument: ["list", "search", "resolve", "show"],
390
+ stock: ["quotes", "bars"],
391
+ index: ["quotes", "bars", "constituents"],
392
+ }[command];
393
+ if (!valid.includes(subcommand)) throw usage(`No such command '${subcommand}'.`);
394
+ return { command, subcommand, arguments_: remaining.slice(2) };
395
+ }
396
+
397
+ async function main(arguments_, environment = process.env) {
398
+ const root = parseRoot(arguments_);
399
+ if (root.help) {
400
+ process.stdout.write(ROOT_HELP);
401
+ return;
402
+ }
403
+ if (!root.remaining.length) throw usage(ROOT_HELP.trimEnd());
404
+ const parts = commandParts(root.remaining);
405
+ if (parts.groupHelp) {
406
+ process.stdout.write(HELP[parts.command]);
407
+ return;
408
+ }
409
+ if (parts.groupMissing) throw usage(HELP[parts.command].trimEnd());
410
+ const context = contextFrom(root.options, environment);
411
+ await dispatch(context, parts.command, parts.subcommand, parts.arguments_);
412
+ }
413
+
414
+ module.exports = { DEFAULT_SERVER_URL, main, parseOptions };
package/lib/config.js ADDED
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
6
+ const { EvaError } = require("./errors.js");
7
+
8
+ function defaultConfigurationPath(environment = process.env) {
9
+ if (environment.EVA_CONFIG) return path.resolve(expandHome(environment.EVA_CONFIG));
10
+ const root = process.platform === "win32"
11
+ ? environment.APPDATA || path.join(os.homedir(), "AppData", "Roaming")
12
+ : environment.XDG_CONFIG_HOME || path.join(os.homedir(), ".config");
13
+ return path.join(root, "eva", "config.json");
14
+ }
15
+
16
+ function expandHome(value) {
17
+ if (value === "~") return os.homedir();
18
+ if (value.startsWith(`~${path.sep}`) || value.startsWith("~/")) {
19
+ return path.join(os.homedir(), value.slice(2));
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function configurationError(message) {
25
+ return new EvaError("INVALID_CONFIGURATION", message);
26
+ }
27
+
28
+ function loadConfiguration(configurationPath) {
29
+ let stats;
30
+ try {
31
+ stats = fs.statSync(configurationPath);
32
+ } catch (error) {
33
+ if (error.code === "ENOENT") return {};
34
+ throw configurationError(`cannot read configuration file: ${configurationPath}`);
35
+ }
36
+ if (!stats.isFile()) {
37
+ throw configurationError(`cannot read configuration file: ${configurationPath}`);
38
+ }
39
+ if (process.platform !== "win32" && (stats.mode & 0o077) !== 0) {
40
+ throw configurationError(
41
+ `configuration file must be owner-only (use chmod 600): ${configurationPath}`,
42
+ );
43
+ }
44
+ let value;
45
+ try {
46
+ value = JSON.parse(fs.readFileSync(configurationPath, "utf8"));
47
+ } catch {
48
+ throw configurationError(`cannot read configuration file: ${configurationPath}`);
49
+ }
50
+ if (!value || Array.isArray(value) || typeof value !== "object" || value.schema !== "eva.cli-config.v1") {
51
+ throw configurationError("configuration schema must be eva.cli-config.v1");
52
+ }
53
+ const allowed = new Set(["schema", "base_url", "api_key"]);
54
+ const unknown = Object.keys(value).filter((key) => !allowed.has(key)).sort();
55
+ if (unknown.length) {
56
+ throw configurationError(`configuration contains unknown field: ${unknown[0]}`);
57
+ }
58
+ if (value.base_url !== undefined && (
59
+ typeof value.base_url !== "string" ||
60
+ !(value.base_url.startsWith("http://") || value.base_url.startsWith("https://"))
61
+ )) {
62
+ throw configurationError("configuration.base_url must be an HTTP(S) URL");
63
+ }
64
+ if (value.api_key !== undefined && (
65
+ typeof value.api_key !== "string" || !value.api_key.trim()
66
+ )) {
67
+ throw configurationError("configuration.api_key must be a non-empty string");
68
+ }
69
+ return { baseUrl: value.base_url, apiKey: value.api_key };
70
+ }
71
+
72
+ function saveConfiguration(configurationPath, configuration) {
73
+ fs.mkdirSync(path.dirname(configurationPath), { recursive: true });
74
+ const value = { schema: "eva.cli-config.v1" };
75
+ if (configuration.baseUrl) value.base_url = configuration.baseUrl;
76
+ if (configuration.apiKey) value.api_key = configuration.apiKey;
77
+ const temporary = path.join(
78
+ path.dirname(configurationPath),
79
+ `.${path.basename(configurationPath)}.${process.pid}.${Date.now()}.tmp`,
80
+ );
81
+ try {
82
+ fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
83
+ encoding: "utf8",
84
+ flag: "wx",
85
+ mode: 0o600,
86
+ });
87
+ fs.chmodSync(temporary, 0o600);
88
+ fs.renameSync(temporary, configurationPath);
89
+ } finally {
90
+ try { fs.unlinkSync(temporary); } catch (error) {
91
+ if (error.code !== "ENOENT") throw error;
92
+ }
93
+ }
94
+ }
95
+
96
+ module.exports = {
97
+ defaultConfigurationPath,
98
+ expandHome,
99
+ loadConfiguration,
100
+ saveConfiguration,
101
+ };
package/lib/errors.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+
3
+ class EvaError extends Error {
4
+ constructor(code, message, retryable = false, exitCode = 1) {
5
+ super(message);
6
+ this.name = "EvaError";
7
+ this.evaProblem = { code, message, retryable, exitCode };
8
+ }
9
+ }
10
+
11
+ function usage(message) {
12
+ return new EvaError("INVALID_ARGUMENT", message, false, 2);
13
+ }
14
+
15
+ module.exports = { EvaError, usage };
@@ -0,0 +1,109 @@
1
+ "use strict";
2
+
3
+ const { EvaError } = require("./errors.js");
4
+
5
+ function sleep(milliseconds) {
6
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
7
+ }
8
+
9
+ class EvaHttpClient {
10
+ constructor(baseUrl, timeout, retries, apiKey, version) {
11
+ if (!(baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))) {
12
+ throw new EvaError(
13
+ "INVALID_SERVER_URL",
14
+ "server URL must start with http:// or https://",
15
+ );
16
+ }
17
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
18
+ this.timeout = timeout;
19
+ this.retries = retries;
20
+ this.apiKey = apiKey;
21
+ this.version = version;
22
+ }
23
+
24
+ async request(method, requestPath, payload) {
25
+ const started = performance.now();
26
+ for (let attempt = 0; attempt <= this.retries; attempt += 1) {
27
+ const remaining = this.timeout * 1000 - (performance.now() - started);
28
+ if (remaining <= 0) {
29
+ throw new EvaError(
30
+ "TIMEOUT",
31
+ `server call exceeded the ${this.timeout} second total timeout`,
32
+ true,
33
+ );
34
+ }
35
+ const controller = new AbortController();
36
+ const timer = setTimeout(() => controller.abort(), remaining);
37
+ try {
38
+ const body = payload === undefined ? undefined : JSON.stringify(payload);
39
+ const response = await fetch(`${this.baseUrl}${requestPath}`, {
40
+ method,
41
+ body,
42
+ signal: controller.signal,
43
+ headers: {
44
+ Accept: "application/json",
45
+ "User-Agent": `eva/${this.version}`,
46
+ ...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
47
+ ...(body ? { "Content-Type": "application/json" } : {}),
48
+ },
49
+ });
50
+ const text = await response.text();
51
+ let parsed;
52
+ try { parsed = JSON.parse(text); } catch {
53
+ if (response.ok) {
54
+ throw new EvaError(
55
+ "INVALID_SERVER_RESPONSE",
56
+ "server returned invalid JSON",
57
+ );
58
+ }
59
+ parsed = {};
60
+ }
61
+ if (!response.ok) {
62
+ const retryable = Boolean(parsed.retryable) || [429, 502, 503, 504].includes(response.status);
63
+ if (retryable && attempt < this.retries) {
64
+ await sleep(Math.min(250 * (2 ** attempt), remaining));
65
+ continue;
66
+ }
67
+ throw new EvaError(
68
+ String(parsed.code || "HTTP_ERROR"),
69
+ String(parsed.detail || `server returned HTTP ${response.status}`),
70
+ retryable,
71
+ );
72
+ }
73
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
74
+ throw new EvaError(
75
+ "INVALID_SERVER_RESPONSE",
76
+ "server returned a non-object JSON response",
77
+ );
78
+ }
79
+ return parsed;
80
+ } catch (error) {
81
+ if (error && error.evaProblem) throw error;
82
+ if (attempt < this.retries) {
83
+ await sleep(Math.min(250 * (2 ** attempt), remaining));
84
+ continue;
85
+ }
86
+ throw new EvaError("SERVER_UNAVAILABLE", "EVA service is unavailable", true);
87
+ } finally {
88
+ clearTimeout(timer);
89
+ }
90
+ }
91
+ throw new Error("HTTP retry loop exhausted");
92
+ }
93
+
94
+ async resolve(query, instrumentType, capability) {
95
+ const response = await this.request("POST", "/v1/instrument-resolve", {
96
+ query,
97
+ context: { instrument_type: instrumentType, capability },
98
+ });
99
+ const data = response.data || {};
100
+ const instrumentId = data.instrument && data.instrument.instrument_id;
101
+ if (data.status === "resolved" && typeof instrumentId === "string") return instrumentId;
102
+ throw new EvaError(
103
+ data.status === "ambiguous" ? "AMBIGUOUS_INSTRUMENT" : "INSTRUMENT_NOT_FOUND",
104
+ "instrument input did not resolve to one supported instrument",
105
+ );
106
+ }
107
+ }
108
+
109
+ module.exports = { EvaHttpClient };
package/lib/output.js ADDED
@@ -0,0 +1,160 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const { EvaError, usage } = require("./errors.js");
6
+
7
+ function serializationError(code, message) {
8
+ return new EvaError(code, message);
9
+ }
10
+
11
+ function applyLimit(value, limit) {
12
+ if (limit === undefined) return value;
13
+ if (!Array.isArray(value)) {
14
+ throw serializationError("LIMIT_NOT_APPLICABLE", "--limit requires a record sequence result");
15
+ }
16
+ return value.slice(0, limit);
17
+ }
18
+
19
+ function dumps(value, { limit, recordBudget = 200 } = {}) {
20
+ const normalized = applyLimit(value, limit);
21
+ if (recordBudget !== null && Array.isArray(normalized) && normalized.length > recordBudget) {
22
+ throw serializationError(
23
+ "RESULT_TOO_LARGE",
24
+ `result has ${normalized.length} records; use --limit or --output`,
25
+ );
26
+ }
27
+ return JSON.stringify(normalized);
28
+ }
29
+
30
+ function validateTarget(target, overwrite) {
31
+ let parent;
32
+ try { parent = fs.statSync(path.dirname(target)); } catch { parent = null; }
33
+ if (!parent || !parent.isDirectory()) {
34
+ throw serializationError("OUTPUT_PARENT_MISSING", "output parent directory does not exist");
35
+ }
36
+ let stats;
37
+ try { stats = fs.lstatSync(target); } catch (error) {
38
+ if (error.code === "ENOENT") return;
39
+ throw error;
40
+ }
41
+ if (!stats.isFile()) {
42
+ throw serializationError("OUTPUT_PATH_INVALID", "output path must be a regular file");
43
+ }
44
+ if (!overwrite) throw serializationError("OUTPUT_EXISTS", "output path already exists");
45
+ }
46
+
47
+ function atomicWrite(target, content, overwrite) {
48
+ validateTarget(target, overwrite);
49
+ const temporary = path.join(
50
+ path.dirname(target),
51
+ `.${path.basename(target)}.${process.pid}.${Date.now()}.tmp`,
52
+ );
53
+ let descriptor;
54
+ try {
55
+ descriptor = fs.openSync(temporary, "wx", 0o600);
56
+ fs.writeFileSync(descriptor, content, "utf8");
57
+ fs.fsyncSync(descriptor);
58
+ fs.closeSync(descriptor);
59
+ descriptor = undefined;
60
+ if (overwrite) fs.renameSync(temporary, target);
61
+ else {
62
+ try { fs.linkSync(temporary, target); } catch (error) {
63
+ if (error.code === "EEXIST") {
64
+ throw serializationError("OUTPUT_EXISTS", "output path already exists");
65
+ }
66
+ throw error;
67
+ }
68
+ fs.unlinkSync(temporary);
69
+ }
70
+ } finally {
71
+ if (descriptor !== undefined) fs.closeSync(descriptor);
72
+ try { fs.unlinkSync(temporary); } catch (error) {
73
+ if (error.code !== "ENOENT") throw error;
74
+ }
75
+ }
76
+ }
77
+
78
+ function csvCell(value) {
79
+ let text;
80
+ if (value === null || value === undefined) text = "";
81
+ else if (typeof value === "boolean") text = value ? "True" : "False";
82
+ else text = String(value);
83
+ if (/[",\r\n]/.test(text)) return `"${text.replaceAll('"', '""')}"`;
84
+ return text;
85
+ }
86
+
87
+ function recordsForFormat(value, format) {
88
+ if (!Array.isArray(value) || !value.every((record) => record && !Array.isArray(record) && typeof record === "object")) {
89
+ throw serializationError(
90
+ "INCOMPATIBLE_OUTPUT_FORMAT",
91
+ `${format} requires a sequence of record objects`,
92
+ );
93
+ }
94
+ return value;
95
+ }
96
+
97
+ function csvContent(value) {
98
+ const records = recordsForFormat(value, "csv");
99
+ if (!records.length) return "";
100
+ const fields = Object.keys(records[0]);
101
+ const expected = new Set(fields);
102
+ for (const record of records) {
103
+ const keys = Object.keys(record);
104
+ if (
105
+ keys.length !== fields.length ||
106
+ keys.some((key) => !expected.has(key)) ||
107
+ Object.values(record).some((item) => item !== null && typeof item === "object")
108
+ ) {
109
+ throw serializationError(
110
+ "INCOMPATIBLE_OUTPUT_FORMAT",
111
+ "csv requires consistent flat record fields",
112
+ );
113
+ }
114
+ }
115
+ const lines = [fields.map(csvCell).join(",")];
116
+ for (const record of records) lines.push(fields.map((field) => csvCell(record[field])).join(","));
117
+ return `${lines.join("\n")}\n`;
118
+ }
119
+
120
+ function exportResult(value, { output, format, overwrite, limit }) {
121
+ const target = path.resolve(output);
122
+ const extension = path.extname(target).slice(1).toLowerCase();
123
+ const known = new Set(["csv", "json", "jsonl", "parquet"]);
124
+ if (format && known.has(extension) && extension !== format) {
125
+ throw serializationError(
126
+ "OUTPUT_FORMAT_MISMATCH",
127
+ "explicit output format conflicts with the file extension",
128
+ );
129
+ }
130
+ const selected = format || extension;
131
+ if (!known.has(selected)) {
132
+ throw serializationError("UNSUPPORTED_OUTPUT_FORMAT", "output format is not supported");
133
+ }
134
+ const normalized = applyLimit(value, limit);
135
+ let content;
136
+ if (selected === "parquet") {
137
+ recordsForFormat(normalized, "parquet");
138
+ throw serializationError(
139
+ "PARQUET_UNAVAILABLE",
140
+ "Parquet output requires the evatick[parquet] extra",
141
+ );
142
+ } else if (selected === "csv") content = csvContent(normalized);
143
+ else if (selected === "jsonl") {
144
+ content = recordsForFormat(normalized, "jsonl").map((record) => JSON.stringify(record)).join("\n");
145
+ if (content) content += "\n";
146
+ } else content = `${JSON.stringify(normalized)}\n`;
147
+ atomicWrite(target, content, overwrite);
148
+ return { path: target, records: Array.isArray(normalized) ? normalized.length : 1 };
149
+ }
150
+
151
+ function emit(value, options = {}) {
152
+ if (!options.output) {
153
+ if (options.format) throw usage("--format requires --output");
154
+ process.stdout.write(`${dumps(value, { limit: options.limit })}\n`);
155
+ return;
156
+ }
157
+ process.stdout.write(`${dumps(exportResult(value, options))}\n`);
158
+ }
159
+
160
+ module.exports = { dumps, emit, exportResult };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evatick/cli",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "EVA market data command-line client for LLMs, agents, and automation",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -17,16 +17,11 @@
17
17
  },
18
18
  "files": [
19
19
  "bin",
20
+ "lib",
20
21
  "README.md",
21
22
  "LICENSE"
22
23
  ],
23
24
  "engines": {
24
25
  "node": ">=18"
25
- },
26
- "optionalDependencies": {
27
- "@evatick/cli-darwin-arm64": "0.3.2",
28
- "@evatick/cli-darwin-x64": "0.3.2",
29
- "@evatick/cli-linux-x64": "0.3.2",
30
- "@evatick/cli-win32-x64": "0.3.2"
31
26
  }
32
27
  }