embedding-studio-cli 0.0.0-stage → 0.1.2
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/LICENSE +7 -0
- package/README.md +24 -2
- package/package.json +25 -4
- package/studio.js +237 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Embedding Space Private Limited
|
|
2
|
+
|
|
3
|
+
Permission is granted to use, copy and redistribute this client, unmodified or
|
|
4
|
+
modified, for the purpose of accessing the Embedding Studio service. Use of the
|
|
5
|
+
service itself is governed by the terms at https://studio.embedding.in/terms.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
package/README.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
1
|
-
#
|
|
1
|
+
# embedding-studio-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The [Embedding Studio](https://studio.embedding.in) API from a terminal. Push labelled
|
|
4
|
+
data, fit and calibrate a decision model, place the review band, ask for decisions, and
|
|
5
|
+
download the calibration file. Everything the console does, behind a contract a script
|
|
6
|
+
or a coding agent can rely on: JSON on stdout, non-zero exit with the API's own message
|
|
7
|
+
on stderr when something is refused.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install -g embedding-studio-cli
|
|
11
|
+
export STUDIO_API_KEY=esk_... # issued once, in the console, under API keys
|
|
12
|
+
|
|
13
|
+
studio create --name "Support routing" --template support-routing
|
|
14
|
+
studio upload support-routing cases.csv --watch
|
|
15
|
+
studio band <fit_id> --precision 0.9
|
|
16
|
+
studio decide support-routing --text "I was billed twice, refund one"
|
|
17
|
+
studio calibration support-routing -o calibration.json
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
No dependencies: Node 18 or newer is the whole requirement. `STUDIO_API_URL` points it
|
|
21
|
+
at a self-hosted deployment. The same tool ships as a single Python file at
|
|
22
|
+
`https://studio.embedding.in/cli.py`; the two carry one command surface, pinned equal
|
|
23
|
+
by a test.
|
|
24
|
+
|
|
25
|
+
Docs: https://studio.embedding.in/docs
|
package/package.json
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "embedding-studio-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "The Embedding Studio API from a terminal: push labelled data, fit and calibrate, place the review band, decide, and download the calibration file. JSON out, non-zero exit on refusal.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"studio": "studio.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"studio.js"
|
|
10
|
+
],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18"
|
|
13
|
+
},
|
|
14
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
15
|
+
"homepage": "https://studio.embedding.in/docs",
|
|
16
|
+
"author": "Embedding Space Private Limited <nandakishor@embedding.in>",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"embedding-studio",
|
|
19
|
+
"calibration",
|
|
20
|
+
"decisions",
|
|
21
|
+
"cli",
|
|
22
|
+
"conformal"
|
|
23
|
+
],
|
|
24
|
+
"bugs": {
|
|
25
|
+
"email": "nandakishor@embedding.in"
|
|
26
|
+
}
|
|
27
|
+
}
|
package/studio.js
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* studio: the Embedding Studio API from a terminal, for the Node world.
|
|
3
|
+
*
|
|
4
|
+
* The same contract as the Python file served at /cli.py: every command prints JSON to stdout,
|
|
5
|
+
* every refusal exits non-zero with the API's own message on stderr, auth is STUDIO_API_KEY in
|
|
6
|
+
* the environment and STUDIO_API_URL points anywhere other than the hosted service. The command
|
|
7
|
+
* surface is pinned equal to the Python client by a test, so the npm install and the curl
|
|
8
|
+
* install cannot drift apart.
|
|
9
|
+
*
|
|
10
|
+
* No dependencies: Node 18 ships fetch, FormData and Blob, which is everything an API client
|
|
11
|
+
* needs. Global install is `npm install -g embedding-studio-cli`, and then `studio projects`.
|
|
12
|
+
*/
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const fs = require("node:fs");
|
|
16
|
+
const path = require("node:path");
|
|
17
|
+
|
|
18
|
+
const DEFAULT_URL = "https://embedding-studio-api.fly.dev";
|
|
19
|
+
|
|
20
|
+
function fail(msg, code) {
|
|
21
|
+
process.stderr.write(msg + "\n");
|
|
22
|
+
process.exit(code);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function say(data) {
|
|
26
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function call(method, p, { body, file, raw } = {}) {
|
|
30
|
+
const base = (opts.url || DEFAULT_URL).replace(/\/+$/, "");
|
|
31
|
+
const headers = {};
|
|
32
|
+
const key = process.env.STUDIO_API_KEY || "";
|
|
33
|
+
if (key) headers["x-api-key"] = key;
|
|
34
|
+
let payload;
|
|
35
|
+
if (file !== undefined) {
|
|
36
|
+
const form = new FormData();
|
|
37
|
+
const name = path.basename(file);
|
|
38
|
+
const type = name.toLowerCase().endsWith(".csv") ? "text/csv" : "text/plain";
|
|
39
|
+
form.append("file", new Blob([fs.readFileSync(file)], { type }), name);
|
|
40
|
+
payload = form;
|
|
41
|
+
} else if (body !== undefined) {
|
|
42
|
+
headers["content-type"] = "application/json";
|
|
43
|
+
payload = JSON.stringify(body);
|
|
44
|
+
}
|
|
45
|
+
let r;
|
|
46
|
+
try {
|
|
47
|
+
r = await fetch(base + p, { method, headers, body: payload });
|
|
48
|
+
} catch (e) {
|
|
49
|
+
fail(`error network: could not reach ${base + p} (${e.cause?.code || e.message})`, 1);
|
|
50
|
+
}
|
|
51
|
+
if (!r.ok) {
|
|
52
|
+
let detail = "";
|
|
53
|
+
const text = await r.text();
|
|
54
|
+
try { detail = JSON.parse(text).detail ?? ""; } catch { /* not json */ }
|
|
55
|
+
fail(`error ${r.status}: ${String(detail) || text.slice(0, 500)}`, 1);
|
|
56
|
+
}
|
|
57
|
+
if (raw) return Buffer.from(await r.arrayBuffer());
|
|
58
|
+
const text = await r.text();
|
|
59
|
+
return text ? JSON.parse(text) : {};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function watchJob(jobId, interval) {
|
|
63
|
+
let last = "";
|
|
64
|
+
for (;;) {
|
|
65
|
+
const job = await call("GET", `/v1/jobs/${jobId}`);
|
|
66
|
+
const line = `${job.state} ${job.stage} ${job.done_rows}/${job.total_rows}`;
|
|
67
|
+
if (line !== last) { process.stderr.write(line + "\n"); last = line; }
|
|
68
|
+
if (["done", "failed", "cancelled"].includes(job.state)) return job;
|
|
69
|
+
await new Promise((res) => setTimeout(res, interval * 1000));
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readStdin() {
|
|
74
|
+
return new Promise((res) => {
|
|
75
|
+
let got = "";
|
|
76
|
+
process.stdin.setEncoding("utf8");
|
|
77
|
+
process.stdin.on("data", (c) => { got += c; });
|
|
78
|
+
process.stdin.on("end", () => res(got));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/* ---- a small flag parser: --name value, --flag, and positionals, nothing cleverer ---- */
|
|
83
|
+
function parse(argv, spec) {
|
|
84
|
+
const out = { _: [] };
|
|
85
|
+
for (let i = 0; i < argv.length; i++) {
|
|
86
|
+
const a = argv[i];
|
|
87
|
+
if (a === "-o") { /* the one short flag argparse gives the Python side */
|
|
88
|
+
out.o = argv[++i];
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (a.startsWith("--")) {
|
|
92
|
+
const name = a.slice(2);
|
|
93
|
+
if (!(name in spec)) fail(`unknown flag --${name}`, 2);
|
|
94
|
+
if (spec[name] === "flag") out[name] = true;
|
|
95
|
+
else out[name] = argv[++i];
|
|
96
|
+
} else out._.push(a);
|
|
97
|
+
}
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* The registry. Names and flags mirror the Python client exactly; the parity test reads them. */
|
|
102
|
+
const COMMANDS = {
|
|
103
|
+
"health": async () => say(await call("GET", "/v1/health")),
|
|
104
|
+
"templates": async () => say(await call("GET", "/v1/templates")),
|
|
105
|
+
"projects": async () => say(await call("GET", "/v1/projects")),
|
|
106
|
+
"project": async (argv) => {
|
|
107
|
+
const a = parse(argv, {});
|
|
108
|
+
say(await call("GET", `/v1/projects/${a._[0]}`));
|
|
109
|
+
},
|
|
110
|
+
"create": async (argv) => {
|
|
111
|
+
const a = parse(argv, { "name": "value", "template": "value", "sheet": "value",
|
|
112
|
+
"text-column": "value" });
|
|
113
|
+
if (!a.name || (!a.template === !a.sheet)) {
|
|
114
|
+
fail("create needs --name and exactly one of --template or --sheet", 2);
|
|
115
|
+
}
|
|
116
|
+
const body = { name: a.name };
|
|
117
|
+
if (a.template) body.template = a.template;
|
|
118
|
+
else body.sheet = JSON.parse(fs.readFileSync(a.sheet, "utf8"));
|
|
119
|
+
if (a["text-column"]) body.text_column = a["text-column"];
|
|
120
|
+
say(await call("POST", "/v1/projects", { body }));
|
|
121
|
+
},
|
|
122
|
+
"delete": async (argv) => {
|
|
123
|
+
const a = parse(argv, { "yes": "flag" });
|
|
124
|
+
if (!a.yes) fail("refusing without --yes: deleting a project has no undo", 2);
|
|
125
|
+
say(await call("DELETE", `/v1/projects/${a._[0]}`));
|
|
126
|
+
},
|
|
127
|
+
"upload": async (argv) => {
|
|
128
|
+
const a = parse(argv, { "watch": "flag", "interval": "value" });
|
|
129
|
+
const out = await call("POST", `/v1/projects/${a._[0]}/datasets`, { file: a._[1] });
|
|
130
|
+
if (a.watch && out.job_id) {
|
|
131
|
+
const job = await watchJob(out.job_id, Number(a.interval || 2));
|
|
132
|
+
say(job);
|
|
133
|
+
process.exit(job.state === "done" ? 0 : 1);
|
|
134
|
+
}
|
|
135
|
+
say(out);
|
|
136
|
+
},
|
|
137
|
+
"sample": async (argv) => {
|
|
138
|
+
const a = parse(argv, { "watch": "flag", "interval": "value" });
|
|
139
|
+
const out = await call("POST", `/v1/projects/${a._[0]}/samples`, { file: a._[1] });
|
|
140
|
+
if (a.watch && out.job_id) {
|
|
141
|
+
const job = await watchJob(out.job_id, Number(a.interval || 2));
|
|
142
|
+
say(job);
|
|
143
|
+
process.exit(job.state === "done" ? 0 : 1);
|
|
144
|
+
}
|
|
145
|
+
say(out);
|
|
146
|
+
},
|
|
147
|
+
"job": async (argv) => {
|
|
148
|
+
const a = parse(argv, { "watch": "flag", "interval": "value" });
|
|
149
|
+
if (a.watch) {
|
|
150
|
+
const job = await watchJob(a._[0], Number(a.interval || 2));
|
|
151
|
+
say(job);
|
|
152
|
+
process.exit(job.state === "done" ? 0 : 1);
|
|
153
|
+
}
|
|
154
|
+
say(await call("GET", `/v1/jobs/${a._[0]}`));
|
|
155
|
+
},
|
|
156
|
+
"stop": async (argv) => say(await call("DELETE", `/v1/jobs/${parse(argv, {})._[0]}`)),
|
|
157
|
+
"restart": async (argv) =>
|
|
158
|
+
say(await call("POST", `/v1/jobs/${parse(argv, {})._[0]}/restart`, { body: {} })),
|
|
159
|
+
"fits": async (argv) => say(await call("GET", `/v1/projects/${parse(argv, {})._[0]}/fits`)),
|
|
160
|
+
"fit": async (argv) => say(await call("GET", `/v1/fits/${parse(argv, {})._[0]}`)),
|
|
161
|
+
"band": async (argv) => {
|
|
162
|
+
const a = parse(argv, { "precision": "value", "review-rate": "value",
|
|
163
|
+
"max-review-rate": "value", "monthly-volume": "value",
|
|
164
|
+
"minutes-per-review": "value", "no-activate": "flag" });
|
|
165
|
+
if (!a.precision === !a["review-rate"]) {
|
|
166
|
+
fail("band needs exactly one of --precision or --review-rate", 2);
|
|
167
|
+
}
|
|
168
|
+
const body = { activate: !a["no-activate"] };
|
|
169
|
+
if (a.precision) body.target_precision = Number(a.precision);
|
|
170
|
+
if (a["review-rate"]) body.review_rate = Number(a["review-rate"]);
|
|
171
|
+
if (a["max-review-rate"]) body.max_review_rate = Number(a["max-review-rate"]);
|
|
172
|
+
if (a["monthly-volume"]) body.monthly_volume = Number(a["monthly-volume"]);
|
|
173
|
+
if (a["minutes-per-review"]) body.minutes_per_review = Number(a["minutes-per-review"]);
|
|
174
|
+
say(await call("POST", `/v1/fits/${a._[0]}/policy`, { body }));
|
|
175
|
+
},
|
|
176
|
+
"serving": async (argv) =>
|
|
177
|
+
say(await call("GET", `/v1/projects/${parse(argv, {})._[0]}/serving`)),
|
|
178
|
+
"calibration": async (argv) => {
|
|
179
|
+
const a = parse(argv, { "out": "value", "o": "value" });
|
|
180
|
+
const buf = await call("GET", `/v1/projects/${a._[0]}/calibration/export`, { raw: true });
|
|
181
|
+
const out = a.out || a.o;
|
|
182
|
+
if (out) {
|
|
183
|
+
fs.writeFileSync(out, buf);
|
|
184
|
+
say({ saved: out, bytes: buf.length });
|
|
185
|
+
} else process.stdout.write(buf.toString("utf8"));
|
|
186
|
+
},
|
|
187
|
+
"decide": async (argv) => {
|
|
188
|
+
const a = parse(argv, { "text": "value", "idempotency-key": "value" });
|
|
189
|
+
const text = a.text !== undefined ? a.text : await readStdin();
|
|
190
|
+
const body = { text };
|
|
191
|
+
if (a["idempotency-key"]) body.idempotency_key = a["idempotency-key"];
|
|
192
|
+
say(await call("POST", `/v1/projects/${a._[0]}/decide`, { body }));
|
|
193
|
+
},
|
|
194
|
+
"ask": async (argv) => {
|
|
195
|
+
const a = parse(argv, { "text": "value", "template": "value", "questions": "value" });
|
|
196
|
+
if (!a.template === !a.questions) fail("ask needs exactly one of --template or --questions", 2);
|
|
197
|
+
const text = a.text !== undefined ? a.text : await readStdin();
|
|
198
|
+
const body = { text };
|
|
199
|
+
if (a.template) body.template = a.template;
|
|
200
|
+
else body.questions = JSON.parse(fs.readFileSync(a.questions, "utf8"));
|
|
201
|
+
say(await call("POST", "/v1/decide", { body }));
|
|
202
|
+
},
|
|
203
|
+
"decisions": async (argv) =>
|
|
204
|
+
say(await call("GET", `/v1/projects/${parse(argv, {})._[0]}/decisions`)),
|
|
205
|
+
"audit": async (argv) => say(await call("GET", `/v1/projects/${parse(argv, {})._[0]}/audit`)),
|
|
206
|
+
"drift": async (argv) => {
|
|
207
|
+
const a = parse(argv, { "sample-id": "value", "min-answers": "value" });
|
|
208
|
+
say(await call("POST", `/v1/projects/${a._[0]}/watch`,
|
|
209
|
+
{ body: { sample_id: a["sample-id"],
|
|
210
|
+
min_answers: Number(a["min-answers"] || 50) } }));
|
|
211
|
+
},
|
|
212
|
+
"keys": async () => say(await call("GET", "/v1/keys")),
|
|
213
|
+
"key-rotate": async (argv) =>
|
|
214
|
+
say(await call("POST", `/v1/keys/${parse(argv, {})._[0]}/rotate`, { body: {} })),
|
|
215
|
+
"key-revoke": async (argv) => say(await call("DELETE", `/v1/keys/${parse(argv, {})._[0]}`)),
|
|
216
|
+
"billing": async () => say(await call("GET", "/v1/billing")),
|
|
217
|
+
"credits": async () => say(await call("GET", "/v1/credits")),
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const opts = {};
|
|
221
|
+
async function main() {
|
|
222
|
+
const argv = process.argv.slice(2);
|
|
223
|
+
if (argv[0] === "--url") { opts.url = argv[1]; argv.splice(0, 2); }
|
|
224
|
+
const cmd = argv.shift();
|
|
225
|
+
if (!cmd || cmd === "--help" || cmd === "-h") {
|
|
226
|
+
process.stderr.write(
|
|
227
|
+
"studio: the Embedding Studio API from a terminal. JSON out, non-zero exit on refusal.\n" +
|
|
228
|
+
"Auth is STUDIO_API_KEY in the environment; STUDIO_API_URL or --url overrides the base.\n" +
|
|
229
|
+
"commands: " + Object.keys(COMMANDS).join(", ") + "\n");
|
|
230
|
+
process.exit(cmd ? 0 : 2);
|
|
231
|
+
}
|
|
232
|
+
if (!(cmd in COMMANDS)) fail(`unknown command ${cmd}`, 2);
|
|
233
|
+
if (!opts.url) opts.url = process.env.STUDIO_API_URL || DEFAULT_URL;
|
|
234
|
+
await COMMANDS[cmd](argv);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
main().catch((e) => fail(`error: ${e.message}`, 1));
|