@erdoai/cli 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 +99 -0
- package/dist/index.js +1082 -0
- package/package.json +52 -0
- package/scripts/postinstall.mjs +42 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# @erdoai/cli
|
|
2
|
+
|
|
3
|
+
Erdo CLI — drive evals (and more) from the terminal or CI, over the same `/v1`
|
|
4
|
+
REST API the MCP tools use.
|
|
5
|
+
|
|
6
|
+
## Install
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
npm install -g @erdoai/cli # global; then run `erdo`
|
|
10
|
+
npx @erdoai/cli --help # or run without installing
|
|
11
|
+
erdo update # self-update to the latest published version
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Requires Node.js 18+. User-facing install docs: https://docs.erdo.ai/cli
|
|
15
|
+
|
|
16
|
+
## Auth
|
|
17
|
+
|
|
18
|
+
Multi-account, like `gh`. Log in with an API key (created in Erdo); it's stored
|
|
19
|
+
per account at `~/.config/erdo/config.json` and tied to your active org.
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
erdo login # browser sign-in (mints + stores a token)
|
|
23
|
+
erdo login --key erdo_api_... # headless/CI: paste an API key instead
|
|
24
|
+
erdo whoami # active account + org
|
|
25
|
+
erdo auth status # all accounts (* = active)
|
|
26
|
+
erdo auth switch [email] # switch active account (auto-toggles with two)
|
|
27
|
+
erdo logout [email]
|
|
28
|
+
|
|
29
|
+
erdo org list # your orgs (* = active)
|
|
30
|
+
erdo org use [id|slug] # set the active org for this account
|
|
31
|
+
erdo --org acme eval suites # one-off override for a single command
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Env overrides for CI/scripting: `ERDO_API_KEY`, `ERDO_ORG`, `ERDO_API_URL`,
|
|
35
|
+
`ERDO_ACCOUNT`.
|
|
36
|
+
|
|
37
|
+
## Agents & pages
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
erdo agent ask "what was revenue last week?" --datasets sales
|
|
41
|
+
erdo agent thread --name "landing build" # -> thread id
|
|
42
|
+
erdo agent send <thread> "Build a landing page for ACME ..." --agent erdo.artifact-builder
|
|
43
|
+
|
|
44
|
+
erdo pages deploy --title "ACME" --html @page.html --js @page.js --public
|
|
45
|
+
erdo pages list --type html_page
|
|
46
|
+
erdo pages get <artifact-id>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`--html/--js/--css` accept `@path` to read a file. Sending a message to a thread
|
|
50
|
+
runs an agent — that's also how the artifact-builder produces a page from a brief.
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
erdo runs list --agent erdo.artifact-builder --status failed # inspect agent runs
|
|
54
|
+
erdo runs get <run-id>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Evals
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
erdo eval suites # list suites (slug, agent, artifact?)
|
|
61
|
+
erdo eval suite landing-variations # show a suite + its cases
|
|
62
|
+
erdo eval run landing-variations --watch # run it and poll until done
|
|
63
|
+
erdo eval results <run-id> # per-case scores + lenses
|
|
64
|
+
erdo eval runs --suite landing-variations # recent runs
|
|
65
|
+
|
|
66
|
+
# maintain the corpus
|
|
67
|
+
erdo eval case add landing-variations \
|
|
68
|
+
--name "voice-concierge" \
|
|
69
|
+
--input "Build a landing page for ACME with a voice concierge (agent id abc123) ..." \
|
|
70
|
+
--rubric '[{"criterion":"voice widget loads and is on-brand","weight":2},{"criterion":"hero + form render correctly","weight":1}]'
|
|
71
|
+
erdo eval case rm landing-variations voice-concierge
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`--watch` exits non-zero if the run doesn't complete cleanly, so it doubles as a
|
|
75
|
+
CI gate.
|
|
76
|
+
|
|
77
|
+
## Develop
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
npm install # or yarn
|
|
81
|
+
npm run dev eval suites # run from source (tsx)
|
|
82
|
+
npm run build:check # typecheck
|
|
83
|
+
npm run build # bundle to dist/ (bin: erdo)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Release
|
|
87
|
+
|
|
88
|
+
Publishing is automated by `.github/workflows/publish-cli.yml`: pushing a
|
|
89
|
+
`cli-v*` tag builds, typechecks, stamps the version from the tag, and runs
|
|
90
|
+
`npm publish` to the npm registry (auth via the repo's `NPM_TOKEN` secret).
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
# bump cli/package.json "version", commit, then tag + push:
|
|
94
|
+
git tag cli-v0.4.1
|
|
95
|
+
git push origin cli-v0.4.1
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The tag's version (`cli-v0.4.1` → `0.4.1`) is the published version; keep
|
|
99
|
+
`cli/package.json` in sync so `erdo --version` matches locally.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1082 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
5
|
+
import { select } from "@inquirer/prompts";
|
|
6
|
+
import { Command } from "commander";
|
|
7
|
+
|
|
8
|
+
// src/config.ts
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
10
|
+
import { homedir } from "os";
|
|
11
|
+
import { dirname, join } from "path";
|
|
12
|
+
var DEFAULT_API_URL = "https://api.erdo.ai";
|
|
13
|
+
var CONFIG_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "erdo");
|
|
14
|
+
var CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
15
|
+
var LEGACY_KEY = "default";
|
|
16
|
+
function loadConfigFile() {
|
|
17
|
+
if (!existsSync(CONFIG_PATH)) return null;
|
|
18
|
+
try {
|
|
19
|
+
const raw = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
20
|
+
if (raw && raw.version === 2 && raw.accounts) return raw;
|
|
21
|
+
if (raw && typeof raw.token === "string") {
|
|
22
|
+
return { version: 2, activeAccount: LEGACY_KEY, accounts: { [LEGACY_KEY]: raw } };
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
function saveConfigFile(file) {
|
|
29
|
+
mkdirSync(dirname(CONFIG_PATH), { recursive: true });
|
|
30
|
+
writeFileSync(CONFIG_PATH, `${JSON.stringify(file, null, 2)}
|
|
31
|
+
`, { mode: 384 });
|
|
32
|
+
}
|
|
33
|
+
function resolveAccountKey(file, query) {
|
|
34
|
+
const keys = Object.keys(file.accounts);
|
|
35
|
+
const q = query.toLowerCase();
|
|
36
|
+
const exact = keys.find(
|
|
37
|
+
(k) => k.toLowerCase() === q || file.accounts[k].email?.toLowerCase() === q
|
|
38
|
+
);
|
|
39
|
+
if (exact) return exact;
|
|
40
|
+
const prefixed = keys.filter((k) => k.toLowerCase().startsWith(q));
|
|
41
|
+
return prefixed.length === 1 ? prefixed[0] : void 0;
|
|
42
|
+
}
|
|
43
|
+
function activeAccountKey(file) {
|
|
44
|
+
if (process.env.ERDO_ACCOUNT) {
|
|
45
|
+
const k = resolveAccountKey(file, process.env.ERDO_ACCOUNT);
|
|
46
|
+
if (k) return k;
|
|
47
|
+
}
|
|
48
|
+
if (file.activeAccount && file.accounts[file.activeAccount]) return file.activeAccount;
|
|
49
|
+
const keys = Object.keys(file.accounts);
|
|
50
|
+
return keys.length ? keys[0] : void 0;
|
|
51
|
+
}
|
|
52
|
+
function activeAccount() {
|
|
53
|
+
const file = loadConfigFile();
|
|
54
|
+
if (!file) return null;
|
|
55
|
+
const k = activeAccountKey(file);
|
|
56
|
+
return k ? { ...file.accounts[k] } : null;
|
|
57
|
+
}
|
|
58
|
+
function listAccounts() {
|
|
59
|
+
const file = loadConfigFile();
|
|
60
|
+
if (!file) return [];
|
|
61
|
+
const active = activeAccountKey(file);
|
|
62
|
+
return Object.entries(file.accounts).map(([key, account]) => ({ key, account, active: key === active }));
|
|
63
|
+
}
|
|
64
|
+
function saveAccount(key, account, activate = true) {
|
|
65
|
+
const file = loadConfigFile() ?? { version: 2, accounts: {} };
|
|
66
|
+
file.accounts[key] = { ...file.accounts[key], ...account };
|
|
67
|
+
if (activate || !file.activeAccount) file.activeAccount = key;
|
|
68
|
+
saveConfigFile(file);
|
|
69
|
+
}
|
|
70
|
+
function updateActiveAccount(patch) {
|
|
71
|
+
const file = loadConfigFile();
|
|
72
|
+
if (!file) return;
|
|
73
|
+
const k = activeAccountKey(file);
|
|
74
|
+
if (!k) return;
|
|
75
|
+
file.accounts[k] = { ...file.accounts[k], ...patch };
|
|
76
|
+
saveConfigFile(file);
|
|
77
|
+
}
|
|
78
|
+
function setActiveAccount(key) {
|
|
79
|
+
const file = loadConfigFile();
|
|
80
|
+
if (!file || !file.accounts[key]) return;
|
|
81
|
+
file.activeAccount = key;
|
|
82
|
+
saveConfigFile(file);
|
|
83
|
+
}
|
|
84
|
+
function removeAccount(key) {
|
|
85
|
+
const file = loadConfigFile();
|
|
86
|
+
if (!file) return;
|
|
87
|
+
delete file.accounts[key];
|
|
88
|
+
if (file.activeAccount === key) file.activeAccount = Object.keys(file.accounts)[0];
|
|
89
|
+
saveConfigFile(file);
|
|
90
|
+
}
|
|
91
|
+
function resolveApiUrl() {
|
|
92
|
+
return (process.env.ERDO_API_URL || DEFAULT_API_URL).replace(/\/$/, "");
|
|
93
|
+
}
|
|
94
|
+
function resolveToken(account) {
|
|
95
|
+
return process.env.ERDO_API_KEY || account?.token;
|
|
96
|
+
}
|
|
97
|
+
function resolveOrgId(account, override) {
|
|
98
|
+
return override || process.env.ERDO_ORG || account?.organizationId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/client.ts
|
|
102
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set(["completed", "failed", "cancelled", "error"]);
|
|
103
|
+
var POLL_STOP_STATUSES = /* @__PURE__ */ new Set([...TERMINAL_RUN_STATUSES, "awaiting_approval"]);
|
|
104
|
+
var ErdoApiError = class extends Error {
|
|
105
|
+
constructor(status, message) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.status = status;
|
|
108
|
+
this.name = "ErdoApiError";
|
|
109
|
+
}
|
|
110
|
+
status;
|
|
111
|
+
};
|
|
112
|
+
async function rawRequest(baseURL, token, orgId, method, path, body) {
|
|
113
|
+
const headers = { Authorization: `Bearer ${token}` };
|
|
114
|
+
if (orgId) headers["X-Organization-ID"] = orgId;
|
|
115
|
+
if (body !== void 0) headers["Content-Type"] = "application/json";
|
|
116
|
+
const res = await fetch(`${baseURL}${path}`, {
|
|
117
|
+
method,
|
|
118
|
+
headers,
|
|
119
|
+
body: body !== void 0 ? JSON.stringify(body) : void 0
|
|
120
|
+
});
|
|
121
|
+
const text = await res.text();
|
|
122
|
+
if (!res.ok) {
|
|
123
|
+
let message = text;
|
|
124
|
+
try {
|
|
125
|
+
const parsed = JSON.parse(text);
|
|
126
|
+
message = parsed.message || parsed.error || text;
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
throw new ErdoApiError(res.status, message || `${method} ${path} failed (${res.status})`);
|
|
130
|
+
}
|
|
131
|
+
return text ? JSON.parse(text) : void 0;
|
|
132
|
+
}
|
|
133
|
+
function fetchMe(token, orgId) {
|
|
134
|
+
return rawRequest(resolveApiUrl(), token, orgId, "GET", "/v1/me");
|
|
135
|
+
}
|
|
136
|
+
var ErdoClient = class {
|
|
137
|
+
baseURL;
|
|
138
|
+
token;
|
|
139
|
+
orgId;
|
|
140
|
+
constructor(orgOverride) {
|
|
141
|
+
this.baseURL = resolveApiUrl();
|
|
142
|
+
const acct = activeAccount();
|
|
143
|
+
const token = resolveToken(acct);
|
|
144
|
+
if (!token) {
|
|
145
|
+
throw new Error("Not logged in. Run: erdo auth login");
|
|
146
|
+
}
|
|
147
|
+
this.token = token;
|
|
148
|
+
this.orgId = resolveOrgId(acct, orgOverride);
|
|
149
|
+
}
|
|
150
|
+
request(method, path, body) {
|
|
151
|
+
return rawRequest(this.baseURL, this.token, this.orgId, method, path, body);
|
|
152
|
+
}
|
|
153
|
+
me() {
|
|
154
|
+
return this.request("GET", "/v1/me");
|
|
155
|
+
}
|
|
156
|
+
listOrganizations() {
|
|
157
|
+
return this.request("GET", "/v1/organizations");
|
|
158
|
+
}
|
|
159
|
+
listEvalSuites() {
|
|
160
|
+
return this.request("GET", "/v1/evals/suites");
|
|
161
|
+
}
|
|
162
|
+
getEvalSuite(slug) {
|
|
163
|
+
return this.request(
|
|
164
|
+
"GET",
|
|
165
|
+
`/v1/evals/suites/${encodeURIComponent(slug)}`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
createEvalSuite(input) {
|
|
169
|
+
return this.request("POST", "/v1/evals/suites", input);
|
|
170
|
+
}
|
|
171
|
+
updateEvalSuite(slug, input) {
|
|
172
|
+
return this.request(
|
|
173
|
+
"PUT",
|
|
174
|
+
`/v1/evals/suites/${encodeURIComponent(slug)}`,
|
|
175
|
+
input
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
addEvalCase(slug, input) {
|
|
179
|
+
return this.request(
|
|
180
|
+
"POST",
|
|
181
|
+
`/v1/evals/suites/${encodeURIComponent(slug)}/cases`,
|
|
182
|
+
input
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
updateEvalCase(slug, caseName, input) {
|
|
186
|
+
return this.request(
|
|
187
|
+
"PUT",
|
|
188
|
+
`/v1/evals/suites/${encodeURIComponent(slug)}/cases/${encodeURIComponent(caseName)}`,
|
|
189
|
+
input
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
removeEvalCase(slug, caseName) {
|
|
193
|
+
return this.request(
|
|
194
|
+
"DELETE",
|
|
195
|
+
`/v1/evals/suites/${encodeURIComponent(slug)}/cases/${encodeURIComponent(caseName)}`
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
runEvalSuite(slug, concurrency) {
|
|
199
|
+
return this.request(
|
|
200
|
+
"POST",
|
|
201
|
+
`/v1/evals/suites/${encodeURIComponent(slug)}/run`,
|
|
202
|
+
{ concurrency: concurrency ?? 0 }
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
getEvalRun(runID) {
|
|
206
|
+
return this.request(
|
|
207
|
+
"GET",
|
|
208
|
+
`/v1/evals/runs/${encodeURIComponent(runID)}`
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
// --- agents ---
|
|
212
|
+
ask(input) {
|
|
213
|
+
return this.request("POST", "/v1/ask", input);
|
|
214
|
+
}
|
|
215
|
+
createThread(input) {
|
|
216
|
+
return this.request(
|
|
217
|
+
"POST",
|
|
218
|
+
"/v1/threads-create",
|
|
219
|
+
input
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
sendMessage(threadID, input) {
|
|
223
|
+
return this.request(
|
|
224
|
+
"POST",
|
|
225
|
+
`/v1/threads/${encodeURIComponent(threadID)}/send`,
|
|
226
|
+
{ thread_id: threadID, ...input }
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
// waitForThreadRun polls the latest run on a thread until it reaches a terminal
|
|
230
|
+
// status — used after an async ask/send so the caller can return the answer
|
|
231
|
+
// without holding a long HTTP request open. Returns the final run (output/error
|
|
232
|
+
// populated). Returns null if the deadline passes WITHOUT the run reaching a
|
|
233
|
+
// terminal status — including the case where it's still running — so callers
|
|
234
|
+
// surface a timeout rather than printing a partial/empty result as success.
|
|
235
|
+
//
|
|
236
|
+
// ignoreRunID is the id of the run that was already latest on the thread BEFORE
|
|
237
|
+
// we kicked off the new one. The async invocation creates its run row a moment
|
|
238
|
+
// after the HTTP call returns, so without this the first poll on a reused thread
|
|
239
|
+
// would return that prior (already-terminal) run and print a stale answer. We
|
|
240
|
+
// skip it and wait for a genuinely new run to appear.
|
|
241
|
+
async waitForThreadRun(threadID, opts = {}) {
|
|
242
|
+
const intervalMs = opts.intervalMs ?? 3e3;
|
|
243
|
+
const deadline = Date.now() + (opts.timeoutMs ?? 15 * 60 * 1e3);
|
|
244
|
+
while (Date.now() < deadline) {
|
|
245
|
+
const { runs } = await this.listAgentRuns({ thread: threadID, limit: 1 });
|
|
246
|
+
const run = runs[0];
|
|
247
|
+
if (run && run.id !== opts.ignoreRunID) {
|
|
248
|
+
opts.onTick?.(run.status);
|
|
249
|
+
if (POLL_STOP_STATUSES.has(run.status)) return run;
|
|
250
|
+
}
|
|
251
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
// latestRunID returns the id of the most recent run on a thread (or undefined),
|
|
256
|
+
// used to baseline an async wait so it doesn't return a pre-existing run.
|
|
257
|
+
async latestRunID(threadID) {
|
|
258
|
+
const { runs } = await this.listAgentRuns({ thread: threadID, limit: 1 });
|
|
259
|
+
return runs[0]?.id;
|
|
260
|
+
}
|
|
261
|
+
listThreads() {
|
|
262
|
+
return this.request("GET", "/v1/threads");
|
|
263
|
+
}
|
|
264
|
+
// --- datasets ---
|
|
265
|
+
listDatasets() {
|
|
266
|
+
return this.request(
|
|
267
|
+
"GET",
|
|
268
|
+
"/v1/datasets"
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
queryDataset(slug, query, timezone) {
|
|
272
|
+
return this.request("POST", `/v1/datasets/${encodeURIComponent(slug)}/query-nl`, {
|
|
273
|
+
query,
|
|
274
|
+
timezone
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// --- knowledge ---
|
|
278
|
+
listKnowledge() {
|
|
279
|
+
return this.request("GET", "/v1/knowledge");
|
|
280
|
+
}
|
|
281
|
+
searchKnowledge(query, limit) {
|
|
282
|
+
const params = new URLSearchParams({ query });
|
|
283
|
+
if (limit) params.set("limit", String(limit));
|
|
284
|
+
return this.request("GET", `/v1/knowledge-search?${params.toString()}`);
|
|
285
|
+
}
|
|
286
|
+
// --- automations (heartbeats) ---
|
|
287
|
+
listHeartbeats() {
|
|
288
|
+
return this.request(
|
|
289
|
+
"GET",
|
|
290
|
+
"/v1/heartbeats"
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
runHeartbeat(id) {
|
|
294
|
+
return this.request("POST", `/v1/heartbeats/${encodeURIComponent(id)}/run`, {});
|
|
295
|
+
}
|
|
296
|
+
// --- collections ---
|
|
297
|
+
listCollections() {
|
|
298
|
+
return this.request("GET", "/v1/collections");
|
|
299
|
+
}
|
|
300
|
+
createCollection(slug) {
|
|
301
|
+
return this.request("POST", "/v1/collections", { slug });
|
|
302
|
+
}
|
|
303
|
+
getCollectionItem(slug, key) {
|
|
304
|
+
return this.request(
|
|
305
|
+
"GET",
|
|
306
|
+
`/v1/collections/${encodeURIComponent(slug)}/items/${encodeURIComponent(key)}`
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
setCollectionItem(slug, key, value) {
|
|
310
|
+
return this.request(
|
|
311
|
+
"PUT",
|
|
312
|
+
`/v1/collections/${encodeURIComponent(slug)}/items/${encodeURIComponent(key)}`,
|
|
313
|
+
{ value }
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
// --- agent runs ---
|
|
317
|
+
listAgentRuns(opts) {
|
|
318
|
+
const params = new URLSearchParams();
|
|
319
|
+
if (opts.agent) params.set("agent_key", opts.agent);
|
|
320
|
+
if (opts.thread) params.set("thread_id", opts.thread);
|
|
321
|
+
if (opts.limit) params.set("limit", String(opts.limit));
|
|
322
|
+
const qs = params.toString();
|
|
323
|
+
return this.request("GET", `/v1/runs${qs ? `?${qs}` : ""}`);
|
|
324
|
+
}
|
|
325
|
+
getAgentRun(id) {
|
|
326
|
+
return this.request("GET", `/v1/runs/${encodeURIComponent(id)}`);
|
|
327
|
+
}
|
|
328
|
+
// --- pages / artifacts ---
|
|
329
|
+
deployPage(input) {
|
|
330
|
+
return this.request("POST", "/v1/pages", input);
|
|
331
|
+
}
|
|
332
|
+
updatePage(pageID, input) {
|
|
333
|
+
return this.request("PUT", `/v1/pages/${encodeURIComponent(pageID)}`, input);
|
|
334
|
+
}
|
|
335
|
+
validatePage(input) {
|
|
336
|
+
return this.request("POST", "/v1/pages/validate", input);
|
|
337
|
+
}
|
|
338
|
+
listArtifacts(type, limit) {
|
|
339
|
+
const params = new URLSearchParams();
|
|
340
|
+
if (type) params.set("type", type);
|
|
341
|
+
if (limit) params.set("limit", String(limit));
|
|
342
|
+
const qs = params.toString();
|
|
343
|
+
return this.request("GET", `/v1/artifacts${qs ? `?${qs}` : ""}`);
|
|
344
|
+
}
|
|
345
|
+
getArtifact(id) {
|
|
346
|
+
return this.request("GET", `/v1/artifacts/${encodeURIComponent(id)}`);
|
|
347
|
+
}
|
|
348
|
+
listEvalRuns(suiteSlug, limit) {
|
|
349
|
+
const params = new URLSearchParams();
|
|
350
|
+
if (suiteSlug) params.set("suite_slug", suiteSlug);
|
|
351
|
+
if (limit) params.set("limit", String(limit));
|
|
352
|
+
const qs = params.toString();
|
|
353
|
+
return this.request("GET", `/v1/evals/runs${qs ? `?${qs}` : ""}`);
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// src/login.ts
|
|
358
|
+
import { spawn } from "child_process";
|
|
359
|
+
import { randomBytes } from "crypto";
|
|
360
|
+
import { createServer } from "http";
|
|
361
|
+
import { hostname } from "os";
|
|
362
|
+
var REDIRECT_URI = "http://localhost:8080/callback";
|
|
363
|
+
var PORT = 8080;
|
|
364
|
+
var LOGIN_TIMEOUT_MS = 3 * 60 * 1e3;
|
|
365
|
+
function openBrowser(url) {
|
|
366
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
367
|
+
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
368
|
+
try {
|
|
369
|
+
spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
|
|
370
|
+
} catch {
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
function waitForCode(state) {
|
|
374
|
+
return new Promise((resolve, reject) => {
|
|
375
|
+
const server = createServer((req, res) => {
|
|
376
|
+
const u = new URL(req.url || "", REDIRECT_URI);
|
|
377
|
+
if (u.pathname !== "/callback") {
|
|
378
|
+
res.writeHead(404);
|
|
379
|
+
res.end();
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
const code = u.searchParams.get("code");
|
|
383
|
+
const gotState = u.searchParams.get("state");
|
|
384
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
385
|
+
res.end(
|
|
386
|
+
'<html><head><meta charset="utf-8"></head><body><h2>Erdo CLI \u2014 authenticated. You can close this tab.</h2></body></html>'
|
|
387
|
+
);
|
|
388
|
+
server.close();
|
|
389
|
+
if (gotState !== state) return reject(new Error("state mismatch \u2014 login aborted"));
|
|
390
|
+
if (!code) return reject(new Error("no authorization code returned"));
|
|
391
|
+
resolve(code);
|
|
392
|
+
});
|
|
393
|
+
server.on("error", (err) => {
|
|
394
|
+
if (err.code === "EADDRINUSE") {
|
|
395
|
+
reject(new Error(`port ${PORT} is in use \u2014 free it and retry (or use 'erdo login --key')`));
|
|
396
|
+
} else {
|
|
397
|
+
reject(err);
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
server.listen(PORT, () => {
|
|
401
|
+
const api = resolveApiUrl();
|
|
402
|
+
const tokenName = (hostname() || "erdo-cli").slice(0, 64);
|
|
403
|
+
const authURL = `${api}/auth/cli?response_type=code&client_id=erdo-cli&redirect_uri=${encodeURIComponent(REDIRECT_URI)}&state=${state}&token_name=${encodeURIComponent(tokenName)}`;
|
|
404
|
+
console.log(`Opening your browser to authorize\u2026
|
|
405
|
+
If it doesn't open, visit:
|
|
406
|
+
${authURL}`);
|
|
407
|
+
openBrowser(authURL);
|
|
408
|
+
});
|
|
409
|
+
setTimeout(() => {
|
|
410
|
+
server.close();
|
|
411
|
+
reject(new Error("login timed out after 3 minutes"));
|
|
412
|
+
}, LOGIN_TIMEOUT_MS).unref();
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
async function browserLogin() {
|
|
416
|
+
const state = randomBytes(16).toString("hex");
|
|
417
|
+
const code = await waitForCode(state);
|
|
418
|
+
const res = await fetch(`${resolveApiUrl()}/auth/cli/callback`, {
|
|
419
|
+
method: "POST",
|
|
420
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
421
|
+
body: new URLSearchParams({
|
|
422
|
+
grant_type: "authorization_code",
|
|
423
|
+
client_id: "erdo-cli",
|
|
424
|
+
code,
|
|
425
|
+
redirect_uri: REDIRECT_URI
|
|
426
|
+
})
|
|
427
|
+
});
|
|
428
|
+
if (!res.ok) {
|
|
429
|
+
throw new Error(`token exchange failed (${res.status}): ${await res.text()}`);
|
|
430
|
+
}
|
|
431
|
+
const data = await res.json();
|
|
432
|
+
if (!data.access_token) throw new Error("token exchange returned no access_token");
|
|
433
|
+
const me = await fetchMe(data.access_token);
|
|
434
|
+
return { token: data.access_token, me };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// src/update.ts
|
|
438
|
+
import { spawnSync } from "child_process";
|
|
439
|
+
var PKG = "@erdoai/cli";
|
|
440
|
+
var MANUAL_HINT = `npm install -g ${PKG}@latest`;
|
|
441
|
+
function isNewer(latest, current) {
|
|
442
|
+
const a = latest.split(".").map(Number);
|
|
443
|
+
const b = current.split(".").map(Number);
|
|
444
|
+
for (let i = 0; i < 3; i++) {
|
|
445
|
+
if ((a[i] ?? 0) > (b[i] ?? 0)) return true;
|
|
446
|
+
if ((a[i] ?? 0) < (b[i] ?? 0)) return false;
|
|
447
|
+
}
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
async function update(current) {
|
|
451
|
+
let latest;
|
|
452
|
+
try {
|
|
453
|
+
const res = await fetch(`https://registry.npmjs.org/${PKG}/latest`);
|
|
454
|
+
if (res.ok) latest = (await res.json()).version;
|
|
455
|
+
} catch {
|
|
456
|
+
}
|
|
457
|
+
if (!latest) {
|
|
458
|
+
console.error(`Could not reach the npm registry. Try manually: ${MANUAL_HINT}`);
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
if (!isNewer(latest, current)) {
|
|
462
|
+
console.log(`Already up to date (v${current}).`);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
console.log(`Updating ${PKG} v${current} \u2192 v${latest}\u2026`);
|
|
466
|
+
const result = spawnSync("npm", ["install", "-g", `${PKG}@latest`], {
|
|
467
|
+
stdio: "inherit",
|
|
468
|
+
// npm is npm.cmd on Windows; needs a shell to resolve.
|
|
469
|
+
shell: process.platform === "win32"
|
|
470
|
+
});
|
|
471
|
+
if (result.status !== 0) {
|
|
472
|
+
console.error(`Update failed. Try manually: ${MANUAL_HINT}`);
|
|
473
|
+
process.exit(result.status ?? 1);
|
|
474
|
+
}
|
|
475
|
+
console.log(`Updated to v${latest}.`);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// src/index.ts
|
|
479
|
+
var pkg = JSON.parse(readFileSync2(new URL("../package.json", import.meta.url), "utf8"));
|
|
480
|
+
function fail(err) {
|
|
481
|
+
if (err instanceof ErdoApiError) {
|
|
482
|
+
console.error(`Error ${err.status}: ${err.message}`);
|
|
483
|
+
} else if (err instanceof Error) {
|
|
484
|
+
console.error(`Error: ${err.message}`);
|
|
485
|
+
} else {
|
|
486
|
+
console.error("Error:", err);
|
|
487
|
+
}
|
|
488
|
+
process.exit(1);
|
|
489
|
+
}
|
|
490
|
+
function print(value) {
|
|
491
|
+
console.log(JSON.stringify(value, null, 2));
|
|
492
|
+
}
|
|
493
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
494
|
+
function summariseResults(results, cases = []) {
|
|
495
|
+
const nameByID = new Map(cases.map((c) => [c.id, c.name]));
|
|
496
|
+
for (const r of results) {
|
|
497
|
+
const status = r.agent_error ? "ERROR" : r.passed ? "PASS" : "FAIL";
|
|
498
|
+
const name = nameByID.get(r.case_id) ?? r.case_id;
|
|
499
|
+
console.log(` [${status}] ${r.score.toFixed(2)} ${name}`);
|
|
500
|
+
if (r.agent_error) console.log(` agent error: ${r.agent_error}`);
|
|
501
|
+
for (const c of r.judge_criteria_scores || []) {
|
|
502
|
+
console.log(` - ${c.score} ${c.criterion}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
var program = new Command();
|
|
507
|
+
program.name("erdo").description("Erdo CLI").version(pkg.version).option("--org <idOrSlug>", "org to target for this command (overrides the active org)");
|
|
508
|
+
program.hook("preAction", (thisCommand) => {
|
|
509
|
+
const org2 = thisCommand.opts().org;
|
|
510
|
+
if (org2) process.env.ERDO_ORG = org2;
|
|
511
|
+
});
|
|
512
|
+
async function doLogin(opts) {
|
|
513
|
+
let token;
|
|
514
|
+
let me;
|
|
515
|
+
if (opts.key) {
|
|
516
|
+
token = opts.key.trim();
|
|
517
|
+
me = await fetchMe(token);
|
|
518
|
+
} else {
|
|
519
|
+
({ token, me } = await browserLogin());
|
|
520
|
+
}
|
|
521
|
+
saveAccount(me.email || me.user_id, {
|
|
522
|
+
token,
|
|
523
|
+
email: me.email,
|
|
524
|
+
organizationId: me.organization_id,
|
|
525
|
+
organizationName: me.organization_name
|
|
526
|
+
});
|
|
527
|
+
console.log(
|
|
528
|
+
`Logged in as ${me.email || me.user_id}${me.organization_name ? ` (org: ${me.organization_name})` : ""}.`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
var auth = program.command("auth").description("Manage authentication");
|
|
532
|
+
auth.command("login").description("Log in via browser (or --key for CI); stored per account").option("--key <apiKey>", "API key for headless/CI (skips the browser flow)").action(async (opts) => {
|
|
533
|
+
try {
|
|
534
|
+
await doLogin(opts);
|
|
535
|
+
} catch (e) {
|
|
536
|
+
fail(e);
|
|
537
|
+
}
|
|
538
|
+
});
|
|
539
|
+
program.command("login").description("Log in via browser (alias for auth login)").option("--key <apiKey>", "API key").action(async (opts) => {
|
|
540
|
+
try {
|
|
541
|
+
await doLogin(opts);
|
|
542
|
+
} catch (e) {
|
|
543
|
+
fail(e);
|
|
544
|
+
}
|
|
545
|
+
});
|
|
546
|
+
auth.command("status").description("List logged-in accounts (* = active)").action(() => {
|
|
547
|
+
const accounts = listAccounts();
|
|
548
|
+
if (accounts.length === 0) {
|
|
549
|
+
console.log("Not logged in. Run: erdo auth login");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
for (const { key, account, active } of accounts) {
|
|
553
|
+
console.log(`${active ? "*" : " "} ${key}`);
|
|
554
|
+
console.log(` token: ${account.token.slice(0, 10)}\u2026`);
|
|
555
|
+
console.log(` org: ${account.organizationName ?? account.organizationId ?? "(none)"}`);
|
|
556
|
+
}
|
|
557
|
+
console.log("Switch with: erdo auth switch");
|
|
558
|
+
});
|
|
559
|
+
auth.command("switch [account]").description("Switch the active account").action(async (accountQuery) => {
|
|
560
|
+
try {
|
|
561
|
+
const file = loadConfigFile();
|
|
562
|
+
const accounts = listAccounts();
|
|
563
|
+
if (!file || accounts.length === 0) throw new Error("Not logged in. Run: erdo auth login");
|
|
564
|
+
let key;
|
|
565
|
+
if (accountQuery) {
|
|
566
|
+
key = resolveAccountKey(file, accountQuery);
|
|
567
|
+
if (!key) throw new Error(`No account matching "${accountQuery}"`);
|
|
568
|
+
} else if (accounts.length === 2) {
|
|
569
|
+
key = accounts.find((a) => !a.active)?.key;
|
|
570
|
+
} else if (accounts.length === 1) {
|
|
571
|
+
console.log("Only one account.");
|
|
572
|
+
return;
|
|
573
|
+
} else {
|
|
574
|
+
key = await select({
|
|
575
|
+
message: "Switch to account:",
|
|
576
|
+
choices: accounts.map((a) => ({
|
|
577
|
+
name: `${a.key}${a.active ? " (current)" : ""}`,
|
|
578
|
+
value: a.key
|
|
579
|
+
}))
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
if (key) {
|
|
583
|
+
setActiveAccount(key);
|
|
584
|
+
console.log(`Switched to ${key}.`);
|
|
585
|
+
}
|
|
586
|
+
} catch (e) {
|
|
587
|
+
fail(e);
|
|
588
|
+
}
|
|
589
|
+
});
|
|
590
|
+
program.command("logout [account]").description("Log out an account (default: the active one)").action((accountQuery) => {
|
|
591
|
+
const file = loadConfigFile();
|
|
592
|
+
if (!file) {
|
|
593
|
+
console.log("Not logged in.");
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
const key = accountQuery ? resolveAccountKey(file, accountQuery) : listAccounts().find((a) => a.active)?.key ?? void 0;
|
|
597
|
+
if (!key) {
|
|
598
|
+
console.log("No matching account.");
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
removeAccount(key);
|
|
602
|
+
console.log(`Logged out ${key}.`);
|
|
603
|
+
});
|
|
604
|
+
program.command("whoami").description("Show the active account and org").action(async () => {
|
|
605
|
+
try {
|
|
606
|
+
const me = await new ErdoClient().me();
|
|
607
|
+
console.log(`${me.email || me.user_id} \u2014 org: ${me.organization_name || me.organization_id}`);
|
|
608
|
+
} catch (e) {
|
|
609
|
+
fail(e);
|
|
610
|
+
}
|
|
611
|
+
});
|
|
612
|
+
program.command("update").description("Update the CLI to the latest published version").action(async () => {
|
|
613
|
+
try {
|
|
614
|
+
await update(pkg.version);
|
|
615
|
+
} catch (e) {
|
|
616
|
+
fail(e);
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
var org = program.command("org").description("Manage the active organization");
|
|
620
|
+
org.command("list").description("List your organizations (* = active)").action(async () => {
|
|
621
|
+
try {
|
|
622
|
+
const api = new ErdoClient();
|
|
623
|
+
const [{ organizations }, me] = await Promise.all([api.listOrganizations(), api.me()]);
|
|
624
|
+
for (const o of organizations) {
|
|
625
|
+
console.log(`${o.id === me.organization_id ? "*" : " "} ${o.slug || "-"} ${o.name} ${o.id}`);
|
|
626
|
+
}
|
|
627
|
+
} catch (e) {
|
|
628
|
+
fail(e);
|
|
629
|
+
}
|
|
630
|
+
});
|
|
631
|
+
org.command("use [idOrSlug]").description("Set the active org for the current account").action(async (idOrSlug) => {
|
|
632
|
+
try {
|
|
633
|
+
const { organizations } = await new ErdoClient().listOrganizations();
|
|
634
|
+
let chosen = idOrSlug ? organizations.find(
|
|
635
|
+
(o) => o.id === idOrSlug || o.slug.toLowerCase() === idOrSlug.toLowerCase()
|
|
636
|
+
) : void 0;
|
|
637
|
+
if (!chosen && !idOrSlug) {
|
|
638
|
+
const id = await select({
|
|
639
|
+
message: "Use organization:",
|
|
640
|
+
choices: organizations.map((o) => ({ name: `${o.name} (${o.slug || o.id})`, value: o.id }))
|
|
641
|
+
});
|
|
642
|
+
chosen = organizations.find((o) => o.id === id);
|
|
643
|
+
}
|
|
644
|
+
if (!chosen) throw new Error(`No org matching "${idOrSlug}"`);
|
|
645
|
+
updateActiveAccount({ organizationId: chosen.id, organizationName: chosen.name });
|
|
646
|
+
console.log(`Active org: ${chosen.name}`);
|
|
647
|
+
} catch (e) {
|
|
648
|
+
fail(e);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
var evalCmd = program.command("eval").description("Run and manage evals");
|
|
652
|
+
evalCmd.command("suites").description("List eval suites in the active org (+ global)").action(async () => {
|
|
653
|
+
try {
|
|
654
|
+
const { suites } = await new ErdoClient().listEvalSuites();
|
|
655
|
+
for (const s of suites) {
|
|
656
|
+
console.log(`${s.slug} ${s.agent_key} artifact=${s.evaluate_artifact} ${s.name}`);
|
|
657
|
+
}
|
|
658
|
+
} catch (e) {
|
|
659
|
+
fail(e);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
evalCmd.command("suite <slug>").description("Show a suite and its cases").action(async (slug) => {
|
|
663
|
+
try {
|
|
664
|
+
print(await new ErdoClient().getEvalSuite(slug));
|
|
665
|
+
} catch (e) {
|
|
666
|
+
fail(e);
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
function parseRubric(json) {
|
|
670
|
+
try {
|
|
671
|
+
return JSON.parse(json);
|
|
672
|
+
} catch {
|
|
673
|
+
throw new Error(`--rubric must be valid JSON, e.g. '[{"criterion":"brand","weight":1}]'`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
var collect = (v, acc) => {
|
|
677
|
+
acc.push(v);
|
|
678
|
+
return acc;
|
|
679
|
+
};
|
|
680
|
+
function caseScoringPayload(opts) {
|
|
681
|
+
const out = {};
|
|
682
|
+
if (opts.rubric) out.rubric = parseRubric(opts.rubric);
|
|
683
|
+
if (opts.evaluator.length > 0) {
|
|
684
|
+
out.evaluators = opts.evaluator.map((e, i) => {
|
|
685
|
+
try {
|
|
686
|
+
return JSON.parse(e);
|
|
687
|
+
} catch {
|
|
688
|
+
throw new Error(`--evaluator #${i + 1} must be valid JSON ({type,weight,...})`);
|
|
689
|
+
}
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
if (!out.rubric && !out.evaluators) {
|
|
693
|
+
throw new Error("provide --rubric and/or --evaluator");
|
|
694
|
+
}
|
|
695
|
+
return out;
|
|
696
|
+
}
|
|
697
|
+
evalCmd.command("update <slug>").description("Update a suite's settings (only the flags you pass change)").option("--name <name>", "new name").option("--description <text>", "new description").option("--agent <key>", "repoint the agent under test, e.g. erdo.data-question-answerer").option("--judge <model>", "new judge model").option("--threshold <n>", "new pass threshold 0-5", (v) => parseFloat(v)).option("--evaluate-artifact <bool>", "judge the rendered page (true/false)").option("--cron <bool>", "include in the daily cron (true/false)").action(
|
|
698
|
+
async (slug, opts) => {
|
|
699
|
+
try {
|
|
700
|
+
const parseBool = (v) => v === void 0 ? void 0 : v === "true" || v === "1" || v === "yes";
|
|
701
|
+
const res = await new ErdoClient().updateEvalSuite(slug, {
|
|
702
|
+
name: opts.name,
|
|
703
|
+
description: opts.description,
|
|
704
|
+
agent_key: opts.agent,
|
|
705
|
+
judge_model: opts.judge,
|
|
706
|
+
pass_threshold: opts.threshold,
|
|
707
|
+
evaluate_artifact: parseBool(opts.evaluateArtifact),
|
|
708
|
+
cron_enabled: parseBool(opts.cron)
|
|
709
|
+
});
|
|
710
|
+
print(res);
|
|
711
|
+
} catch (e) {
|
|
712
|
+
fail(e);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
);
|
|
716
|
+
evalCmd.command("create <name>").description("Create a suite (in the active org)").requiredOption("--agent <key>", "agent under test, e.g. erdo.artifact-builder").option("--description <text>", "description").option("--judge <model>", "judge model").option("--threshold <n>", "pass threshold 0-5", (v) => parseFloat(v)).option("--evaluate-artifact", "judge the rendered page (visual suite)").option("--no-cron", "exclude from the daily cron (recommended for artifact suites)").requiredOption("--case <json>", "first case: {name,input,rubric,tags?}", (v, acc) => {
|
|
717
|
+
acc.push(v);
|
|
718
|
+
return acc;
|
|
719
|
+
}, []).action(
|
|
720
|
+
async (name, opts) => {
|
|
721
|
+
try {
|
|
722
|
+
const cases = opts.case.map((c) => JSON.parse(c));
|
|
723
|
+
const res = await new ErdoClient().createEvalSuite({
|
|
724
|
+
name,
|
|
725
|
+
description: opts.description,
|
|
726
|
+
agent_key: opts.agent,
|
|
727
|
+
judge_model: opts.judge,
|
|
728
|
+
pass_threshold: opts.threshold,
|
|
729
|
+
evaluate_artifact: !!opts.evaluateArtifact,
|
|
730
|
+
cron_enabled: opts.cron,
|
|
731
|
+
// commander sets false when --no-cron
|
|
732
|
+
cases
|
|
733
|
+
});
|
|
734
|
+
print(res);
|
|
735
|
+
} catch (e) {
|
|
736
|
+
fail(e);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
);
|
|
740
|
+
evalCmd.command("run <slug>").description("Run a suite; --watch polls until it completes").option("-w, --watch", "poll until the run finishes and print results").option("-c, --concurrency <n>", "parallel cases", (v) => parseInt(v, 10)).action(async (slug, opts) => {
|
|
741
|
+
try {
|
|
742
|
+
const api = new ErdoClient();
|
|
743
|
+
const { run_id } = await api.runEvalSuite(slug, opts.concurrency);
|
|
744
|
+
console.log(`run_id: ${run_id}`);
|
|
745
|
+
if (!opts.watch) return;
|
|
746
|
+
for (; ; ) {
|
|
747
|
+
await sleep(5e3);
|
|
748
|
+
const { run, results, cases } = await api.getEvalRun(run_id);
|
|
749
|
+
if (run.status === "running") {
|
|
750
|
+
console.log(` ...running (${results.length}/${run.total_cases} cases scored)`);
|
|
751
|
+
continue;
|
|
752
|
+
}
|
|
753
|
+
console.log(
|
|
754
|
+
`
|
|
755
|
+
${run.status}: ${run.passed_cases}/${run.total_cases} passed, avg ${run.avg_score.toFixed(2)}`
|
|
756
|
+
);
|
|
757
|
+
summariseResults(results, cases);
|
|
758
|
+
if (run.status !== "completed" || run.passed_cases < run.total_cases) {
|
|
759
|
+
process.exitCode = 1;
|
|
760
|
+
}
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
} catch (e) {
|
|
764
|
+
fail(e);
|
|
765
|
+
}
|
|
766
|
+
});
|
|
767
|
+
evalCmd.command("results <runId>").description("Show a run's results").option("--json", "print the full JSON").action(async (runId, opts) => {
|
|
768
|
+
try {
|
|
769
|
+
const data = await new ErdoClient().getEvalRun(runId);
|
|
770
|
+
if (opts.json) {
|
|
771
|
+
print(data);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
const { run, results, cases } = data;
|
|
775
|
+
console.log(
|
|
776
|
+
`${run.status}: ${run.passed_cases}/${run.total_cases} passed, avg ${run.avg_score.toFixed(2)}`
|
|
777
|
+
);
|
|
778
|
+
summariseResults(results, cases);
|
|
779
|
+
} catch (e) {
|
|
780
|
+
fail(e);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
evalCmd.command("runs").description("List recent runs").option("-s, --suite <slug>", "filter to one suite").option("-l, --limit <n>", "max runs", (v) => parseInt(v, 10)).action(async (opts) => {
|
|
784
|
+
try {
|
|
785
|
+
const { runs } = await new ErdoClient().listEvalRuns(opts.suite, opts.limit);
|
|
786
|
+
for (const r of runs) {
|
|
787
|
+
console.log(
|
|
788
|
+
`${r.id} ${r.status} ${r.passed_cases}/${r.total_cases} avg ${r.avg_score.toFixed(2)}`
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
} catch (e) {
|
|
792
|
+
fail(e);
|
|
793
|
+
}
|
|
794
|
+
});
|
|
795
|
+
var caseCmd = evalCmd.command("case").description("Manage cases in a suite");
|
|
796
|
+
caseCmd.command("add <slug>").description("Add a case to a suite").requiredOption("--name <name>", "case name").requiredOption("--input <brief>", "the brief/prompt sent to the agent (the evaluated turn)").option(
|
|
797
|
+
"--setup <msg>",
|
|
798
|
+
"a turn to run BEFORE input, same thread/agent (repeatable; e.g. 'create a voice widget for X')",
|
|
799
|
+
collect,
|
|
800
|
+
[]
|
|
801
|
+
).option("--rubric <json>", 'LLM rubric: JSON array of {"criterion","weight"}').option("--evaluator <json>", "evaluator JSON {type,weight,...} (repeatable)", collect, []).option("--tags <csv>", "comma-separated tags").action(
|
|
802
|
+
async (slug, opts) => {
|
|
803
|
+
try {
|
|
804
|
+
const res = await new ErdoClient().addEvalCase(slug, {
|
|
805
|
+
name: opts.name,
|
|
806
|
+
input: opts.input,
|
|
807
|
+
setup_messages: opts.setup,
|
|
808
|
+
...caseScoringPayload(opts),
|
|
809
|
+
tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : []
|
|
810
|
+
});
|
|
811
|
+
print(res);
|
|
812
|
+
} catch (e) {
|
|
813
|
+
fail(e);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
);
|
|
817
|
+
caseCmd.command("update <slug> <caseName>").description("Replace a case's brief, scoring, and tags").requiredOption("--input <brief>", "replacement brief/prompt (the evaluated turn)").option("--setup <msg>", "replacement pre-turn run before input (repeatable; omit for none)", collect, []).option("--rubric <json>", "replacement LLM rubric JSON").option("--evaluator <json>", "evaluator JSON {type,weight,...} (repeatable)", collect, []).option("--tags <csv>", "comma-separated tags").action(
|
|
818
|
+
async (slug, caseName, opts) => {
|
|
819
|
+
try {
|
|
820
|
+
const res = await new ErdoClient().updateEvalCase(slug, caseName, {
|
|
821
|
+
name: caseName,
|
|
822
|
+
input: opts.input,
|
|
823
|
+
setup_messages: opts.setup,
|
|
824
|
+
...caseScoringPayload(opts),
|
|
825
|
+
tags: opts.tags ? opts.tags.split(",").map((t) => t.trim()) : []
|
|
826
|
+
});
|
|
827
|
+
print(res);
|
|
828
|
+
} catch (e) {
|
|
829
|
+
fail(e);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
);
|
|
833
|
+
caseCmd.command("rm <slug> <caseName>").description("Archive a case from a suite").action(async (slug, caseName) => {
|
|
834
|
+
try {
|
|
835
|
+
await new ErdoClient().removeEvalCase(slug, caseName);
|
|
836
|
+
console.log(`removed: ${caseName}`);
|
|
837
|
+
} catch (e) {
|
|
838
|
+
fail(e);
|
|
839
|
+
}
|
|
840
|
+
});
|
|
841
|
+
var agentCmd = program.command("agent").description("Run agents");
|
|
842
|
+
agentCmd.command("ask <question>").description("Ask the data-question agent (one-shot); prints the answer").option("-d, --datasets <csv>", "dataset slugs to scope to").option(
|
|
843
|
+
"--async",
|
|
844
|
+
"kick off the run and poll for the result instead of holding one long request open (use for slow builds)"
|
|
845
|
+
).action(async (question, opts) => {
|
|
846
|
+
try {
|
|
847
|
+
const client = new ErdoClient();
|
|
848
|
+
const res = await client.ask({
|
|
849
|
+
question,
|
|
850
|
+
dataset_slugs: opts.datasets ? opts.datasets.split(",").map((s) => s.trim()) : void 0,
|
|
851
|
+
async: opts.async
|
|
852
|
+
});
|
|
853
|
+
if (opts.async) {
|
|
854
|
+
process.stderr.write(`thread: ${res.thread_id}
|
|
855
|
+
running\u2026`);
|
|
856
|
+
const run = await client.waitForThreadRun(res.thread_id, {
|
|
857
|
+
onTick: () => process.stderr.write(".")
|
|
858
|
+
});
|
|
859
|
+
process.stderr.write("\n");
|
|
860
|
+
if (!run) throw new Error("timed out waiting for the run to finish");
|
|
861
|
+
if (run.status === "awaiting_approval")
|
|
862
|
+
throw new Error("run needs approval \u2014 approve it in the Erdo app, then re-check the thread");
|
|
863
|
+
if (run.error) throw new Error(run.error);
|
|
864
|
+
console.log(run.output || `(status: ${run.status})`);
|
|
865
|
+
console.log(`
|
|
866
|
+
thread: ${res.thread_id}`);
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
console.log(res.answer || `(status: ${res.status})`);
|
|
870
|
+
console.log(`
|
|
871
|
+
thread: ${res.thread_id}`);
|
|
872
|
+
} catch (e) {
|
|
873
|
+
fail(e);
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
agentCmd.command("send <threadId> <message>").description("Send a message to a thread and print the agent's reply").option("-a, --agent <key>", "agent to invoke (e.g. erdo.artifact-builder)").option(
|
|
877
|
+
"--async",
|
|
878
|
+
"kick off the run and poll for the result instead of holding one long request open (use for slow builds)"
|
|
879
|
+
).action(async (threadId, message, opts) => {
|
|
880
|
+
try {
|
|
881
|
+
const client = new ErdoClient();
|
|
882
|
+
const baselineRunID = opts.async ? await client.latestRunID(threadId) : void 0;
|
|
883
|
+
const res = await client.sendMessage(threadId, {
|
|
884
|
+
message,
|
|
885
|
+
agent_key: opts.agent,
|
|
886
|
+
async: opts.async
|
|
887
|
+
});
|
|
888
|
+
if (res.error) throw new Error(res.error);
|
|
889
|
+
if (opts.async) {
|
|
890
|
+
process.stderr.write("running\u2026");
|
|
891
|
+
const run = await client.waitForThreadRun(threadId, {
|
|
892
|
+
ignoreRunID: baselineRunID,
|
|
893
|
+
onTick: () => process.stderr.write(".")
|
|
894
|
+
});
|
|
895
|
+
process.stderr.write("\n");
|
|
896
|
+
if (!run) throw new Error("timed out waiting for the run to finish");
|
|
897
|
+
if (run.status === "awaiting_approval")
|
|
898
|
+
throw new Error("run needs approval \u2014 approve it in the Erdo app, then re-check the thread");
|
|
899
|
+
if (run.error) throw new Error(run.error);
|
|
900
|
+
console.log(run.output || `(status: ${run.status})`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
console.log(res.answer || `(status: ${res.status})`);
|
|
904
|
+
} catch (e) {
|
|
905
|
+
fail(e);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
agentCmd.command("thread").description("Create a thread; prints its id").option("-n, --name <name>", "thread name").option("-d, --datasets <csv>", "dataset UUIDs to attach").action(async (opts) => {
|
|
909
|
+
try {
|
|
910
|
+
const res = await new ErdoClient().createThread({
|
|
911
|
+
name: opts.name,
|
|
912
|
+
dataset_ids: opts.datasets ? opts.datasets.split(",").map((s) => s.trim()) : void 0
|
|
913
|
+
});
|
|
914
|
+
console.log(res.id);
|
|
915
|
+
} catch (e) {
|
|
916
|
+
fail(e);
|
|
917
|
+
}
|
|
918
|
+
});
|
|
919
|
+
agentCmd.command("threads").description("List your threads").action(async () => {
|
|
920
|
+
try {
|
|
921
|
+
const { threads } = await new ErdoClient().listThreads();
|
|
922
|
+
for (const t of threads) console.log(`${t.id} ${t.name}`);
|
|
923
|
+
} catch (e) {
|
|
924
|
+
fail(e);
|
|
925
|
+
}
|
|
926
|
+
});
|
|
927
|
+
var runsCmd = program.command("runs").description("Inspect agent runs");
|
|
928
|
+
runsCmd.command("list").description("List agent runs (filter by agent OR thread \u2014 mutually exclusive)").option("-a, --agent <key>", "filter by agent key").option("-t, --thread <id>", "filter to a thread").option("-l, --limit <n>", "max runs", (v) => parseInt(v, 10)).action(async (opts) => {
|
|
929
|
+
try {
|
|
930
|
+
const { runs } = await new ErdoClient().listAgentRuns(opts);
|
|
931
|
+
for (const r of runs) console.log(`${r.id} ${r.status} ${r.agent_key}`);
|
|
932
|
+
} catch (e) {
|
|
933
|
+
fail(e);
|
|
934
|
+
}
|
|
935
|
+
});
|
|
936
|
+
runsCmd.command("get <id>").description("Show an agent run (status, output, trace metadata)").action(async (id) => {
|
|
937
|
+
try {
|
|
938
|
+
print(await new ErdoClient().getAgentRun(id));
|
|
939
|
+
} catch (e) {
|
|
940
|
+
fail(e);
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
var pagesCmd = program.command("pages").description("Create and manage pages/artifacts");
|
|
944
|
+
function readMaybeFile(v) {
|
|
945
|
+
if (!v) return void 0;
|
|
946
|
+
return v.startsWith("@") ? readFileSync2(v.slice(1), "utf8") : v;
|
|
947
|
+
}
|
|
948
|
+
pagesCmd.command("deploy").description("Deploy a page (HTML/React); --html/--js/--css accept @file").requiredOption("--title <title>", "page title").requiredOption("--html <htmlOr@file>", "HTML (or @path to a file)").option("--js <jsOr@file>", "JS/JSX (or @path)").option("--css <cssOr@file>", "CSS (or @path)").option("--runtime <runtime>", "react-tailwind (default) or none").option("--datasets <csv>", "dataset slugs the page reads").option("--writable-datasets <csv>", "dataset slugs the page may write via erdo.insertRows").option("--collections <csv>", "named collection slugs the page reads").option("--writable-collections <csv>", "named collection slugs the page may write").option("--public", "make the page publicly viewable").action(
|
|
949
|
+
async (opts) => {
|
|
950
|
+
try {
|
|
951
|
+
const csv = (v) => v ? v.split(",").map((s) => s.trim()) : void 0;
|
|
952
|
+
const res = await new ErdoClient().deployPage({
|
|
953
|
+
title: opts.title,
|
|
954
|
+
html: readMaybeFile(opts.html),
|
|
955
|
+
js: readMaybeFile(opts.js),
|
|
956
|
+
css: readMaybeFile(opts.css),
|
|
957
|
+
runtime: opts.runtime,
|
|
958
|
+
dataset_slugs: csv(opts.datasets),
|
|
959
|
+
writable_dataset_slugs: csv(opts.writableDatasets),
|
|
960
|
+
collection_slugs: csv(opts.collections),
|
|
961
|
+
writable_collection_slugs: csv(opts.writableCollections),
|
|
962
|
+
public: !!opts.public
|
|
963
|
+
});
|
|
964
|
+
console.log(`${res.id} ${res.public_url || res.url}`);
|
|
965
|
+
if (res.warning) console.error(`warning: ${res.warning}`);
|
|
966
|
+
} catch (e) {
|
|
967
|
+
fail(e);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
);
|
|
971
|
+
pagesCmd.command("validate").description("Validate page content without deploying").requiredOption("--html <htmlOr@file>", "HTML (or @path)").option("--js <jsOr@file>", "JS/JSX (or @path)").option("--css <cssOr@file>", "CSS (or @path)").option("--runtime <runtime>", "react-tailwind (default) or none").action(async (opts) => {
|
|
972
|
+
try {
|
|
973
|
+
print(
|
|
974
|
+
await new ErdoClient().validatePage({
|
|
975
|
+
html: readMaybeFile(opts.html),
|
|
976
|
+
js: readMaybeFile(opts.js),
|
|
977
|
+
css: readMaybeFile(opts.css),
|
|
978
|
+
runtime: opts.runtime
|
|
979
|
+
})
|
|
980
|
+
);
|
|
981
|
+
} catch (e) {
|
|
982
|
+
fail(e);
|
|
983
|
+
}
|
|
984
|
+
});
|
|
985
|
+
pagesCmd.command("list").description("List artifacts (pages, charts, ...)").option("-t, --type <type>", "filter by type (e.g. html_page)").option("-l, --limit <n>", "max results", (v) => parseInt(v, 10)).action(async (opts) => {
|
|
986
|
+
try {
|
|
987
|
+
const { artifacts } = await new ErdoClient().listArtifacts(opts.type, opts.limit);
|
|
988
|
+
for (const a of artifacts) console.log(`${a.id} ${a.type} ${a.title}`);
|
|
989
|
+
} catch (e) {
|
|
990
|
+
fail(e);
|
|
991
|
+
}
|
|
992
|
+
});
|
|
993
|
+
pagesCmd.command("get <id>").description("Show an artifact").action(async (id) => {
|
|
994
|
+
try {
|
|
995
|
+
print(await new ErdoClient().getArtifact(id));
|
|
996
|
+
} catch (e) {
|
|
997
|
+
fail(e);
|
|
998
|
+
}
|
|
999
|
+
});
|
|
1000
|
+
var datasetsCmd = program.command("datasets").description("Datasets");
|
|
1001
|
+
datasetsCmd.command("list").description("List datasets").action(async () => {
|
|
1002
|
+
try {
|
|
1003
|
+
const { datasets } = await new ErdoClient().listDatasets();
|
|
1004
|
+
for (const d of datasets) console.log(`${d.slug} ${d.type} ${d.status} ${d.name}`);
|
|
1005
|
+
} catch (e) {
|
|
1006
|
+
fail(e);
|
|
1007
|
+
}
|
|
1008
|
+
});
|
|
1009
|
+
datasetsCmd.command("query <slug> <question>").description("Ask a natural-language question of a dataset").action(async (slug, question) => {
|
|
1010
|
+
try {
|
|
1011
|
+
print(await new ErdoClient().queryDataset(slug, question));
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
fail(e);
|
|
1014
|
+
}
|
|
1015
|
+
});
|
|
1016
|
+
var knowledgeCmd = program.command("knowledge").description("Knowledge objects");
|
|
1017
|
+
knowledgeCmd.command("list").description("List knowledge objects").action(async () => {
|
|
1018
|
+
try {
|
|
1019
|
+
print(await new ErdoClient().listKnowledge());
|
|
1020
|
+
} catch (e) {
|
|
1021
|
+
fail(e);
|
|
1022
|
+
}
|
|
1023
|
+
});
|
|
1024
|
+
knowledgeCmd.command("search <query>").description("Semantic search over knowledge").option("-l, --limit <n>", "max results", (v) => parseInt(v, 10)).action(async (query, opts) => {
|
|
1025
|
+
try {
|
|
1026
|
+
print(await new ErdoClient().searchKnowledge(query, opts.limit));
|
|
1027
|
+
} catch (e) {
|
|
1028
|
+
fail(e);
|
|
1029
|
+
}
|
|
1030
|
+
});
|
|
1031
|
+
var autoCmd = program.command("automations").description("Scheduled automations (heartbeats)");
|
|
1032
|
+
autoCmd.command("list").description("List automations").action(async () => {
|
|
1033
|
+
try {
|
|
1034
|
+
const { heartbeats } = await new ErdoClient().listHeartbeats();
|
|
1035
|
+
for (const h of heartbeats) console.log(`${h.id} ${h.state} ${h.name}`);
|
|
1036
|
+
} catch (e) {
|
|
1037
|
+
fail(e);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
autoCmd.command("run <id>").description("Trigger an automation now").action(async (id) => {
|
|
1041
|
+
try {
|
|
1042
|
+
print(await new ErdoClient().runHeartbeat(id));
|
|
1043
|
+
} catch (e) {
|
|
1044
|
+
fail(e);
|
|
1045
|
+
}
|
|
1046
|
+
});
|
|
1047
|
+
var collCmd = program.command("collections").description("Named key/value collections");
|
|
1048
|
+
collCmd.command("list").description("List collections").action(async () => {
|
|
1049
|
+
try {
|
|
1050
|
+
print(await new ErdoClient().listCollections());
|
|
1051
|
+
} catch (e) {
|
|
1052
|
+
fail(e);
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
collCmd.command("create <slug>").description("Create a named collection (slug: lowercase letters, digits, hyphens)").action(async (slug) => {
|
|
1056
|
+
try {
|
|
1057
|
+
print(await new ErdoClient().createCollection(slug));
|
|
1058
|
+
} catch (e) {
|
|
1059
|
+
fail(e);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
collCmd.command("get <slug> <key>").description("Get a collection item").action(async (slug, key) => {
|
|
1063
|
+
try {
|
|
1064
|
+
print(await new ErdoClient().getCollectionItem(slug, key));
|
|
1065
|
+
} catch (e) {
|
|
1066
|
+
fail(e);
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
collCmd.command("set <slug> <key> <jsonValue>").description("Set a collection item (value is parsed as JSON, else stored as string)").action(async (slug, key, jsonValue) => {
|
|
1070
|
+
try {
|
|
1071
|
+
let value;
|
|
1072
|
+
try {
|
|
1073
|
+
value = JSON.parse(jsonValue);
|
|
1074
|
+
} catch {
|
|
1075
|
+
value = jsonValue;
|
|
1076
|
+
}
|
|
1077
|
+
print(await new ErdoClient().setCollectionItem(slug, key, value));
|
|
1078
|
+
} catch (e) {
|
|
1079
|
+
fail(e);
|
|
1080
|
+
}
|
|
1081
|
+
});
|
|
1082
|
+
program.parseAsync(process.argv).catch(fail);
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@erdoai/cli",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Erdo CLI — drive datasets, pages, and evals from the terminal or CI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"erdo": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"scripts/postinstall.mjs"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public",
|
|
18
|
+
"registry": "https://registry.npmjs.org/"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"erdo",
|
|
23
|
+
"ai",
|
|
24
|
+
"agent",
|
|
25
|
+
"cli",
|
|
26
|
+
"datasets",
|
|
27
|
+
"evals"
|
|
28
|
+
],
|
|
29
|
+
"repository": {
|
|
30
|
+
"type": "git",
|
|
31
|
+
"url": "git+https://github.com/erdoai/erdo.git",
|
|
32
|
+
"directory": "cli"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://docs.erdo.ai/cli",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsup src/index.ts --format esm --clean",
|
|
37
|
+
"build:check": "tsc --noEmit",
|
|
38
|
+
"dev": "tsx src/index.ts",
|
|
39
|
+
"postinstall": "node scripts/postinstall.mjs",
|
|
40
|
+
"prepublishOnly": "npm run build"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"@inquirer/prompts": "^7.5.0",
|
|
44
|
+
"commander": "^12.1.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^22.0.0",
|
|
48
|
+
"tsup": "^8.0.0",
|
|
49
|
+
"tsx": "^4.0.0",
|
|
50
|
+
"typescript": "^5.7.0"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Friendly getting-started hint, printed once after `npm install -g @erdoai/cli`.
|
|
2
|
+
// Stays quiet in CI and non-interactive installs (and never fails the install).
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
|
|
5
|
+
try {
|
|
6
|
+
// In our own source checkout the package ships `dist` + this script but never
|
|
7
|
+
// `src`, so a sibling `src/` means this is a dev `npm install` — stay quiet.
|
|
8
|
+
const devCheckout = existsSync(new URL("../src", import.meta.url));
|
|
9
|
+
|
|
10
|
+
const quiet =
|
|
11
|
+
devCheckout ||
|
|
12
|
+
process.env.CI ||
|
|
13
|
+
process.env.ERDO_NO_POSTINSTALL ||
|
|
14
|
+
!process.stdout.isTTY;
|
|
15
|
+
|
|
16
|
+
if (!quiet) {
|
|
17
|
+
const c = (code, s) => `\x1b[${code}m${s}\x1b[0m`;
|
|
18
|
+
const b = (s) => c("1", s);
|
|
19
|
+
const dim = (s) => c("2", s);
|
|
20
|
+
const blue = (s) => c("34", s);
|
|
21
|
+
|
|
22
|
+
const lines = [
|
|
23
|
+
"",
|
|
24
|
+
b(" Erdo CLI installed.") +
|
|
25
|
+
" " +
|
|
26
|
+
dim("Drive agents, pages, datasets, and evals from your terminal or CI."),
|
|
27
|
+
"",
|
|
28
|
+
b(" Get started:"),
|
|
29
|
+
` ${b("erdo login")} ${dim("Sign in via your browser")}`,
|
|
30
|
+
` ${b("erdo org use")} ${dim("Choose your organization")}`,
|
|
31
|
+
` ${b('erdo agent ask "..."')} ${dim("Ask your first question")}`,
|
|
32
|
+
"",
|
|
33
|
+
` Docs ${blue("https://docs.erdo.ai/cli")}`,
|
|
34
|
+
` Quickstart ${blue("https://docs.erdo.ai/quickstart")}`,
|
|
35
|
+
` Use with AI ${blue("https://docs.erdo.ai/mcp/overview")} ${dim("(Claude, Codex, Cursor…)")}`,
|
|
36
|
+
"",
|
|
37
|
+
];
|
|
38
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
39
|
+
}
|
|
40
|
+
} catch {
|
|
41
|
+
// never let a cosmetic hint break installation
|
|
42
|
+
}
|