@evatick/cli 0.3.2 → 0.4.1
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 +80 -12
- package/bin/eva.js +16 -37
- package/lib/cli.js +414 -0
- package/lib/config.js +101 -0
- package/lib/errors.js +15 -0
- package/lib/http-client.js +109 -0
- package/lib/output.js +160 -0
- package/package.json +17 -8
package/README.md
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
# EVA CLI
|
|
1
|
+
# EVA Tick CLI
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
Deterministic, machine-readable financial data for AI agents, LLMs, and
|
|
4
|
+
automation.
|
|
5
|
+
|
|
6
|
+
EVA Tick gives agents canonical instrument IDs, stable JSON, structured errors,
|
|
7
|
+
and predictable commands instead of provider-specific identifiers and
|
|
8
|
+
functions.
|
|
9
|
+
|
|
10
|
+

|
|
5
11
|
|
|
6
12
|
## Install
|
|
7
13
|
|
|
@@ -10,19 +16,81 @@ npm install --global @evatick/cli
|
|
|
10
16
|
eva version
|
|
11
17
|
```
|
|
12
18
|
|
|
13
|
-
|
|
19
|
+
Run it without installing:
|
|
14
20
|
|
|
15
21
|
```shell
|
|
16
22
|
npx @evatick/cli health
|
|
17
23
|
```
|
|
18
24
|
|
|
19
|
-
The npm package
|
|
20
|
-
not
|
|
21
|
-
|
|
25
|
+
The npm package is implemented in Node.js, has no runtime dependencies, and
|
|
26
|
+
does not require Python or platform-specific native executables.
|
|
27
|
+
|
|
28
|
+
## First query
|
|
29
|
+
|
|
30
|
+
```shell
|
|
31
|
+
eva health
|
|
32
|
+
eva instrument search --query 平安银行 --type equity --limit 3
|
|
33
|
+
eva instrument resolve --query 000001 --type equity
|
|
34
|
+
eva stock bars --symbol cn:equity:XSHE:000001 \
|
|
35
|
+
--interval 1d \
|
|
36
|
+
--start 2026-08-18 \
|
|
37
|
+
--end 2026-08-21 \
|
|
38
|
+
--adjustment forward \
|
|
39
|
+
--limit 3
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Common commands
|
|
43
|
+
|
|
44
|
+
- `eva instrument search` finds canonical financial instruments by name, code,
|
|
45
|
+
or alias.
|
|
46
|
+
- `eva stock bars` queries normalized OHLCV bars.
|
|
47
|
+
- `eva index constituents` queries index constituent memberships.
|
|
48
|
+
|
|
49
|
+
Run `eva COMMAND --help` or `eva GROUP COMMAND --help` for supported options.
|
|
50
|
+
|
|
51
|
+
## Hosted or self-hosted
|
|
52
|
+
|
|
53
|
+
The CLI connects to the hosted EVA Tick service at `https://api.evatick.com` by
|
|
54
|
+
default. Point it at a self-hosted [EVA Tick Server](https://github.com/xiaochaohit/evatick-server)
|
|
55
|
+
temporarily with an environment variable:
|
|
56
|
+
|
|
57
|
+
```shell
|
|
58
|
+
EVA_SERVER_URL=http://127.0.0.1:8765 eva health
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Or save the service URL:
|
|
62
|
+
|
|
63
|
+
```shell
|
|
64
|
+
eva config set --base-url http://127.0.0.1:8765
|
|
65
|
+
eva config show
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
When a service requires authentication, use `EVA_API_KEY` or
|
|
69
|
+
`eva config set --api-key 'eva_...'`. The CLI sends a key only when configured,
|
|
70
|
+
and `eva config show` masks stored keys.
|
|
71
|
+
|
|
72
|
+
## Current availability
|
|
73
|
+
|
|
74
|
+
EVA Tick is designed as a provider-independent financial data layer. The
|
|
75
|
+
current public service starts with mainland China A-shares and indices published
|
|
76
|
+
by the Shanghai Stock Exchange, Shenzhen Stock Exchange, and China Securities
|
|
77
|
+
Index. Availability varies by deployed service and enabled providers.
|
|
78
|
+
|
|
79
|
+
## Output and safety
|
|
80
|
+
|
|
81
|
+
- Successful results are compact JSON on stdout.
|
|
82
|
+
- Failures are structured JSON on stderr with stable error codes.
|
|
83
|
+
- JSON, JSONL, and CSV file exports are supported by the npm CLI.
|
|
84
|
+
- Existing files are never overwritten unless `--overwrite` is explicit.
|
|
85
|
+
- Parquet export is available in the Python distribution with
|
|
86
|
+
`pip install 'evatick[parquet]'`.
|
|
87
|
+
|
|
88
|
+
## Documentation
|
|
22
89
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
90
|
+
- [Complete CLI documentation and examples](https://github.com/xiaochaohit/evatick-cli#readme)
|
|
91
|
+
- [EVA Tick Skill for Codex](https://www.npmjs.com/package/@evatick/skill)
|
|
92
|
+
- [Self-hosted server and OpenAPI contract](https://github.com/xiaochaohit/evatick-server)
|
|
93
|
+
- [License and data-source notices](https://github.com/xiaochaohit/evatick-cli#license)
|
|
26
94
|
|
|
27
|
-
|
|
28
|
-
|
|
95
|
+
Market data is provided for research and reference only and does not constitute
|
|
96
|
+
investment advice.
|
package/bin/eva.js
CHANGED
|
@@ -2,42 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
"use strict";
|
|
4
4
|
|
|
5
|
-
const {
|
|
5
|
+
const { main } = require("../lib/cli.js");
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evatick/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "EVA
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "EVA Tick CLI for deterministic, machine-readable financial data queries by AI agents, LLMs, and automation",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
@@ -17,16 +17,25 @@
|
|
|
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
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
|
|
27
|
+
"keywords": [
|
|
28
|
+
"eva-tick",
|
|
29
|
+
"financial-data",
|
|
30
|
+
"market-data",
|
|
31
|
+
"ai-agent",
|
|
32
|
+
"llm",
|
|
33
|
+
"codex",
|
|
34
|
+
"automation",
|
|
35
|
+
"quantitative-finance",
|
|
36
|
+
"openapi",
|
|
37
|
+
"akshare",
|
|
38
|
+
"a-share",
|
|
39
|
+
"china-stock-market"
|
|
40
|
+
]
|
|
32
41
|
}
|