trodo-cli 0.0.0-stage → 0.3.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 +33 -2
- package/bin/trodo.js +7 -0
- package/lib/args.js +45 -0
- package/lib/commands/agents.js +63 -0
- package/lib/commands/api.js +37 -0
- package/lib/commands/auth.js +165 -0
- package/lib/commands/conversations.js +49 -0
- package/lib/commands/datasets.js +182 -0
- package/lib/commands/evals.js +138 -0
- package/lib/commands/issues.js +40 -0
- package/lib/commands/prompts.js +125 -0
- package/lib/commands/runs.js +118 -0
- package/lib/commands/setup.js +34 -0
- package/lib/commands/spans.js +38 -0
- package/lib/commands/traces.js +112 -0
- package/lib/commands/users.js +14 -0
- package/lib/common.js +69 -0
- package/lib/config.js +78 -0
- package/lib/filters.js +157 -0
- package/lib/http.js +74 -0
- package/lib/main.js +137 -0
- package/lib/output.js +61 -0
- package/package.json +29 -4
- package/skills/trodo/SKILL.md +121 -0
- package/skills/trodo-datasets/SKILL.md +116 -0
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# trodo-cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Trodo from the terminal, a script, CI or a coding agent: read and measure your
|
|
4
|
+
agents' traces, see which eval step failed, triage and close issues, pull and
|
|
5
|
+
push prompts, build and label datasets, run agents, and export fine-tuning files.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm i -g trodo-cli
|
|
9
|
+
trodo login # opens the app to connect this terminal
|
|
10
|
+
trodo setup claude # give Claude Code the Trodo skills
|
|
11
|
+
|
|
12
|
+
trodo traces stats --since 7d --compare
|
|
13
|
+
trodo runs list --since 24h --eval "any:failed" --table
|
|
14
|
+
trodo runs tree RUN_ID
|
|
15
|
+
trodo evals verdicts --run RUN_ID --table
|
|
16
|
+
trodo conversations get CONVERSATION_ID --messages
|
|
17
|
+
trodo issues list --status open,regression --table
|
|
18
|
+
trodo prompts pull support-triage --label production -o prompt.json
|
|
19
|
+
trodo prompts push support-triage -f prompt.json --message "why" --label staging
|
|
20
|
+
|
|
21
|
+
trodo traces export --kind spans --since 30d --eval any:passed --format openai --target output -o sft.jsonl
|
|
22
|
+
trodo datasets create "Good refunds" --kind runs --eval "Refund policy:passed" --include-trace
|
|
23
|
+
trodo datasets export "Good refunds" --rows passed --format openai --target output -o train.jsonl
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
`trodo help` lists every command and filter. Output is JSON (`--table` for a
|
|
27
|
+
table, `--csv` for CSV; lists take `--all`). Exit codes: 0 ok, 1 API error, 2 usage.
|
|
28
|
+
`trodo api GET /path` reaches any route of the CLI's API.
|
|
29
|
+
|
|
30
|
+
Auth: `trodo login` (browser), `trodo login --key trodo_mk_…`, or
|
|
31
|
+
`TRODO_API_KEY` / `TRODO_API_URL`. Keys live in Settings → MCP; one acts as
|
|
32
|
+
the person who made it, on its team, with its scopes.
|
|
33
|
+
|
|
34
|
+
Docs: https://docs.trodo.ai/platform/cli
|
package/bin/trodo.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// `trodo … | head` closes the pipe early: that is done, not an error.
|
|
5
|
+
process.stdout.on('error', (e) => { if (e && e.code === 'EPIPE') process.exit(0); throw e; });
|
|
6
|
+
|
|
7
|
+
require('../lib/main').main(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
package/lib/args.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Flags: `--name value`, `--name=value`, `--flag` (true), `-o file`, `-f file`.
|
|
5
|
+
* A flag given twice collects into an array (`--where a --where b`).
|
|
6
|
+
* `--` ends flags. Everything else is positional.
|
|
7
|
+
*/
|
|
8
|
+
const SHORT = { o: 'out', f: 'file', h: 'help', n: 'limit' };
|
|
9
|
+
const BOOLEAN = new Set(['help', 'json', 'table', 'live', 'include-trace', 'wait', 'yes', 'no-trace', 'no-browser', 'any-span', 'tree', 'messages', 'failed', 'passed', 'right', 'wrong', 'remove', 'compare', 'all', 'csv', 'global']);
|
|
10
|
+
|
|
11
|
+
function parse(argv) {
|
|
12
|
+
const flags = {};
|
|
13
|
+
const positional = [];
|
|
14
|
+
const put = (k, v) => {
|
|
15
|
+
if (flags[k] === undefined) flags[k] = v;
|
|
16
|
+
else flags[k] = [].concat(flags[k], v);
|
|
17
|
+
};
|
|
18
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
19
|
+
const a = argv[i];
|
|
20
|
+
if (a === '--') { positional.push(...argv.slice(i + 1)); break; }
|
|
21
|
+
if (a.startsWith('--')) {
|
|
22
|
+
const eq = a.indexOf('=');
|
|
23
|
+
const key = eq > 0 ? a.slice(2, eq) : a.slice(2);
|
|
24
|
+
if (eq > 0) { put(key, a.slice(eq + 1)); continue; }
|
|
25
|
+
if (BOOLEAN.has(key)) { put(key, true); continue; }
|
|
26
|
+
const next = argv[i + 1];
|
|
27
|
+
if (next === undefined || (next.startsWith('--') && next.length > 2)) { put(key, true); continue; }
|
|
28
|
+
put(key, next); i += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (/^-[a-zA-Z]$/.test(a)) {
|
|
32
|
+
const key = SHORT[a[1]] || a[1];
|
|
33
|
+
if (BOOLEAN.has(key)) { put(key, true); continue; }
|
|
34
|
+
put(key, argv[i + 1]); i += 1;
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
positional.push(a);
|
|
38
|
+
}
|
|
39
|
+
return { flags, positional };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A flag as a list (absent → []). */
|
|
43
|
+
const list = (v) => (v === undefined || v === true ? [] : [].concat(v)).map(String);
|
|
44
|
+
|
|
45
|
+
module.exports = { parse, list };
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
|
|
7
|
+
const usage = (msg) => { const e = new Error(msg); e.exitCode = 2; return e; };
|
|
8
|
+
const DONE = new Set(['succeeded', 'failed', 'cancelled', 'canceled', 'stopped']);
|
|
9
|
+
|
|
10
|
+
async function resolveAgent(api, ref) {
|
|
11
|
+
if (!ref) throw usage('Name the agent (its name or id).');
|
|
12
|
+
const { workflows } = await api.get('/agents');
|
|
13
|
+
const all = workflows || [];
|
|
14
|
+
const hit = all.find((w) => w.id === ref) || all.find((w) => w.name === ref) || all.find((w) => w.name.toLowerCase() === String(ref).toLowerCase());
|
|
15
|
+
if (!hit) throw usage(`No agent "${ref}". \`trodo agents list\` shows them.`);
|
|
16
|
+
return hit;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** trodo agents get AGENT: the agent with its steps, trigger and published version. */
|
|
20
|
+
async function getAgent([ref], flags) {
|
|
21
|
+
const api = client(config.resolve(flags));
|
|
22
|
+
const a = await resolveAgent(api, ref);
|
|
23
|
+
print(await api.get(`/agents/${a.id}`), flags);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function listAgents(_a, flags) {
|
|
27
|
+
const { workflows } = await client(config.resolve(flags)).get('/agents');
|
|
28
|
+
print(workflows || [], flags, [['name', 'Agent', 44], ['status', 'Status', 10], ['id', 'Id', 36], ['updated_at', 'Updated', 24]]);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** trodo agents run AGENT [--input JSON] [--wait]: start its published version, as Run manually does. */
|
|
32
|
+
async function run([ref], flags) {
|
|
33
|
+
const api = client(config.resolve(flags));
|
|
34
|
+
const a = await resolveAgent(api, ref);
|
|
35
|
+
let input = null;
|
|
36
|
+
if (flags.input) { try { input = JSON.parse(String(flags.input)); } catch (e) { throw usage(`--input is not JSON: ${e.message}`); } }
|
|
37
|
+
const { execution } = await api.post(`/agents/${a.id}/executions`, { input });
|
|
38
|
+
if (!flags.wait) return print({ agent: a.name, execution }, flags);
|
|
39
|
+
let exec = execution;
|
|
40
|
+
const deadline = Date.now() + (Number(flags.timeout) || 900) * 1000;
|
|
41
|
+
while (!DONE.has(String(exec.status)) && Date.now() < deadline) {
|
|
42
|
+
// eslint-disable-next-line no-await-in-loop
|
|
43
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
44
|
+
// eslint-disable-next-line no-await-in-loop
|
|
45
|
+
exec = (await api.get(`/agents/executions/${exec.id}`, { lite: 1 })).execution;
|
|
46
|
+
}
|
|
47
|
+
print({ agent: a.name, execution: exec }, flags);
|
|
48
|
+
if (exec.status === 'failed') process.exitCode = 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function executions([ref], flags) {
|
|
52
|
+
const api = client(config.resolve(flags));
|
|
53
|
+
const a = await resolveAgent(api, ref);
|
|
54
|
+
const out = await api.get(`/agents/${a.id}/executions`, { limit: flags.limit || 20 });
|
|
55
|
+
print(out.executions || out, flags, [['id', 'Execution', 36], ['status', 'Status', 10], ['started_at', 'Started', 24], ['finished_at', 'Finished', 24]]);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function execution([id], flags) {
|
|
59
|
+
if (!id) throw usage('Usage: trodo agents execution EXECUTION_ID');
|
|
60
|
+
print(await client(config.resolve(flags)).get(`/agents/executions/${encodeURIComponent(id)}`), flags);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { get: getAgent, list: listAgents, run, executions, execution };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const config = require('../config');
|
|
5
|
+
const { client } = require('../http');
|
|
6
|
+
const { print } = require('../output');
|
|
7
|
+
const { usage } = require('../common');
|
|
8
|
+
const { list: flagList } = require('../args');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* trodo api METHOD PATH [--param k=v …] [--data JSON | -f body.json]
|
|
12
|
+
* Any /api/v1 route with the signed-in key, for what no command covers yet.
|
|
13
|
+
* Reads are open; the server refuses any write that deletes, archives or replaces:
|
|
14
|
+
* trodo api GET /runs/conversations --param q=alice
|
|
15
|
+
* trodo api POST /datasets --data '{"name":"Scratch"}'
|
|
16
|
+
*/
|
|
17
|
+
async function call([method, path], flags) {
|
|
18
|
+
const m = String(method || '').toUpperCase();
|
|
19
|
+
if (!['GET', 'POST', 'PATCH', 'PUT', 'DELETE'].includes(m) || !path) throw usage('Usage: trodo api GET|POST|PATCH|PUT|DELETE /path [--param k=v] [--data JSON | -f body.json]');
|
|
20
|
+
const query = {};
|
|
21
|
+
for (const kv of flagList(flags.param)) {
|
|
22
|
+
const i = kv.indexOf('=');
|
|
23
|
+
if (i < 1) throw usage(`--param takes k=v, got "${kv}".`);
|
|
24
|
+
query[kv.slice(0, i)] = kv.slice(i + 1);
|
|
25
|
+
}
|
|
26
|
+
let body;
|
|
27
|
+
const raw = flags.data !== undefined && flags.data !== true ? String(flags.data) : (flags.file && flags.file !== true ? fs.readFileSync(String(flags.file), 'utf8') : null);
|
|
28
|
+
if (raw != null) { try { body = JSON.parse(raw); } catch (e) { throw usage(`The body is not JSON: ${e.message}`); } }
|
|
29
|
+
const p = String(path).startsWith('/') ? String(path) : `/${path}`;
|
|
30
|
+
const res = await client(config.resolve(flags)).raw(m, p, { query, body });
|
|
31
|
+
const text = await res.text();
|
|
32
|
+
let data; try { data = text ? JSON.parse(text) : null; } catch { data = text; }
|
|
33
|
+
if (!res.ok) { process.exitCode = 1; process.stderr.write(`trodo: HTTP ${res.status}\n`); }
|
|
34
|
+
if (typeof data === 'string') process.stdout.write(`${data}\n`); else print(data, flags);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = { call };
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
|
|
7
|
+
async function readHidden(prompt) {
|
|
8
|
+
if (!process.stdin.isTTY) {
|
|
9
|
+
const chunks = [];
|
|
10
|
+
for await (const c of process.stdin) chunks.push(c);
|
|
11
|
+
return Buffer.concat(chunks).toString('utf8').trim();
|
|
12
|
+
}
|
|
13
|
+
process.stdout.write(prompt);
|
|
14
|
+
return new Promise((resolve) => {
|
|
15
|
+
let v = '';
|
|
16
|
+
process.stdin.setRawMode(true); process.stdin.resume();
|
|
17
|
+
const onData = (b) => {
|
|
18
|
+
const s = b.toString('utf8');
|
|
19
|
+
if (s === '\r' || s === '\n' || s === '\u0004') { process.stdin.setRawMode(false); process.stdin.pause(); process.stdin.off('data', onData); process.stdout.write('\n'); resolve(v.trim()); }
|
|
20
|
+
else if (s === '\u0003') { process.stdout.write('\n'); process.exit(130); }
|
|
21
|
+
else if (s === '\u007f') v = v.slice(0, -1);
|
|
22
|
+
else v += s;
|
|
23
|
+
};
|
|
24
|
+
process.stdin.on('data', onData);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Open a URL in the default browser; false when there is none to open. */
|
|
29
|
+
function openBrowser(url) {
|
|
30
|
+
// eslint-disable-next-line global-require
|
|
31
|
+
const { spawn } = require('child_process');
|
|
32
|
+
const cmd = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]];
|
|
33
|
+
try {
|
|
34
|
+
const p = spawn(cmd[0], cmd[1], { stdio: 'ignore', detached: true });
|
|
35
|
+
p.on('error', () => {});
|
|
36
|
+
p.unref();
|
|
37
|
+
return true;
|
|
38
|
+
} catch { return false; }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
42
|
+
|
|
43
|
+
/** The browser sign-in: a code here, Connect in the app, a key back. */
|
|
44
|
+
async function browserLogin(url, flags) {
|
|
45
|
+
// eslint-disable-next-line global-require
|
|
46
|
+
const os = require('os');
|
|
47
|
+
// eslint-disable-next-line global-require
|
|
48
|
+
const pkg = require('../../package.json');
|
|
49
|
+
const fetchImpl = globalThis.fetch;
|
|
50
|
+
const post = async (p, body) => {
|
|
51
|
+
const res = await fetchImpl(`${url}/api/v1${p}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'User-Agent': `trodo-cli/${pkg.version}` }, body: JSON.stringify(body) });
|
|
52
|
+
const data = await res.json().catch(() => ({}));
|
|
53
|
+
if (!res.ok) { const e = new Error(data.message || data.error || `HTTP ${res.status} from ${url}`); e.status = res.status; throw e; }
|
|
54
|
+
return data;
|
|
55
|
+
};
|
|
56
|
+
const start = await post('/auth/device', { host: os.hostname().replace(/\.local$/, ''), client: `trodo-cli ${pkg.version}` });
|
|
57
|
+
process.stderr.write(`\nYour code: ${start.user_code}\n`);
|
|
58
|
+
const opened = !flags['no-browser'] && openBrowser(start.verification_url);
|
|
59
|
+
process.stderr.write(`${opened ? 'Opened' : 'Open'} ${start.verification_url}\n${opened ? 'Check the code there and press Connect.' : 'Sign in there, check the code and press Connect.'} Waiting…\n`);
|
|
60
|
+
const deadline = Date.now() + (start.expires_in || 600) * 1000;
|
|
61
|
+
while (Date.now() < deadline) {
|
|
62
|
+
// eslint-disable-next-line no-await-in-loop
|
|
63
|
+
await sleep((start.interval || 2) * 1000);
|
|
64
|
+
// eslint-disable-next-line no-await-in-loop
|
|
65
|
+
const out = await post('/auth/device/token', { device_code: start.device_code });
|
|
66
|
+
if (out.status === 'approved') return out.api_key;
|
|
67
|
+
if (out.status === 'denied') { const e = new Error('Sign-in was refused in the browser.'); e.exitCode = 1; throw e; }
|
|
68
|
+
if (out.status === 'expired') break;
|
|
69
|
+
}
|
|
70
|
+
const e = new Error('The sign-in code expired. Run `trodo login` again.');
|
|
71
|
+
e.exitCode = 1;
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* trodo login [--url URL] [--no-browser]: sign in through the app (a code, Connect, done).
|
|
77
|
+
* trodo login --key trodo_mk_…: use a key you made in Settings → MCP (CI, no browser).
|
|
78
|
+
* Saved to ~/.trodo/config.json, mode 600.
|
|
79
|
+
*/
|
|
80
|
+
async function login(_args, flags) {
|
|
81
|
+
const url = config.resolve(flags).url;
|
|
82
|
+
let key;
|
|
83
|
+
// --key K uses that key; --key alone (or --key -) reads it from the terminal or stdin.
|
|
84
|
+
if (flags.key === true || flags.key === '-') key = await readHidden('API key (trodo_mk_…, Settings → MCP): ');
|
|
85
|
+
else if (flags.key) key = String(flags.key);
|
|
86
|
+
else key = await browserLogin(url, flags);
|
|
87
|
+
if (!key.startsWith('trodo_mk_')) { const e = new Error('That is not a Trodo API key (they start with trodo_mk_).'); e.exitCode = 2; throw e; }
|
|
88
|
+
const me = await client({ url, key }).get('/me');
|
|
89
|
+
await adoptOldKey();
|
|
90
|
+
const before = config.teams().filter((t) => String(t.id) !== String(me.team.id));
|
|
91
|
+
const file = config.saveTeam({ id: me.team.id, name: me.team.name || String(me.team.id), key, url });
|
|
92
|
+
process.stderr.write(`Signed in to ${me.team.name} as ${me.user.email}. Saved to ${file}.${before.length ? ` Also signed in to ${before.map((t) => t.name).join(', ')}: \`trodo switch\` moves between them.` : ''}\n`);
|
|
93
|
+
print({ team: me.team, scopes: me.key.scopes }, flags);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A 0.2 config held one key and no team: ask the server whose it is and put it
|
|
98
|
+
* in the list, so signing in to a second team keeps the first. Silent when the
|
|
99
|
+
* key no longer works (it is then simply dropped by the next login).
|
|
100
|
+
*/
|
|
101
|
+
async function adoptOldKey(keepCurrent = true) {
|
|
102
|
+
const cfg = config.read();
|
|
103
|
+
if (!cfg.api_key || (cfg.teams && Object.keys(cfg.teams).length)) return;
|
|
104
|
+
const url = String(cfg.api_url || config.DEFAULT_URL).replace(/\/+$/, '');
|
|
105
|
+
try {
|
|
106
|
+
const me = await client({ url, key: cfg.api_key }).get('/me');
|
|
107
|
+
const teams = { [String(me.team.id)]: { name: me.team.name || String(me.team.id), api_key: cfg.api_key, api_url: url } };
|
|
108
|
+
config.write({ api_url: url, current_team: keepCurrent ? String(me.team.id) : null, teams });
|
|
109
|
+
} catch { /* an old key that no longer works is not worth keeping */ }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** trodo teams: the teams this terminal is signed in to; the current one is marked. */
|
|
113
|
+
async function teams(_args, flags) {
|
|
114
|
+
await adoptOldKey();
|
|
115
|
+
const rows = config.teams().map(({ api_key: k, ...t }) => ({ ...t, current: t.current ? '*' : '' }));
|
|
116
|
+
if (!rows.length) {
|
|
117
|
+
const legacy = config.read().api_key;
|
|
118
|
+
process.stderr.write(legacy ? 'Signed in with one key from an older trodo; `trodo login` adds teams by name.\n' : 'Not signed in. `trodo login` adds a team.\n');
|
|
119
|
+
}
|
|
120
|
+
print(flags.table ? rows : rows.map((r) => ({ ...r, current: r.current === '*' })), flags, [['current', '', 1], ['name', 'Team', 40], ['id', 'Id', 8], ['api_url', 'Server', 30]]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** trodo switch TEAM: the team every command uses from now on (`--team` picks one for a single command). */
|
|
124
|
+
async function switchTeam([ref], flags) {
|
|
125
|
+
if (!ref) { const e = new Error('Usage: trodo switch TEAM (`trodo teams` lists them)'); e.exitCode = 2; throw e; }
|
|
126
|
+
await adoptOldKey();
|
|
127
|
+
const cfg = config.read();
|
|
128
|
+
const t = config.findTeam(cfg, ref);
|
|
129
|
+
if (!t) { const e = new Error(`Not signed in to a team called "${ref}". \`trodo teams\` lists yours; \`trodo login\` adds one.`); e.exitCode = 2; throw e; }
|
|
130
|
+
config.write({ ...cfg, current_team: t.id });
|
|
131
|
+
process.stderr.write(`Now on ${t.name}.\n`);
|
|
132
|
+
print({ team: { id: t.id, name: t.name } }, flags);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function whoami(_args, flags) {
|
|
136
|
+
print(await client(config.resolve(flags)).get('/me'), flags);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* trodo logout [TEAM] [--all]: forget the current team's key (or TEAM's, or
|
|
141
|
+
* every one). The key itself stays valid until revoked in Settings → MCP.
|
|
142
|
+
*/
|
|
143
|
+
async function logout([ref], flags) {
|
|
144
|
+
const cfg = config.read();
|
|
145
|
+
const all = config.teams(cfg);
|
|
146
|
+
let removed = [];
|
|
147
|
+
if (flags.all || !all.length) {
|
|
148
|
+
removed = all.map((t) => t.name);
|
|
149
|
+
config.write({ api_url: cfg.api_url });
|
|
150
|
+
} else {
|
|
151
|
+
const t = ref ? config.findTeam(cfg, ref) : all.find((x) => x.current) || all[0];
|
|
152
|
+
if (!t) { const e = new Error(`Not signed in to a team called "${ref}".`); e.exitCode = 2; throw e; }
|
|
153
|
+
const rest = { ...cfg.teams };
|
|
154
|
+
delete rest[t.id];
|
|
155
|
+
const left = Object.keys(rest);
|
|
156
|
+
const current = String(cfg.current_team) === t.id ? (left[0] || null) : cfg.current_team;
|
|
157
|
+
config.write({ api_url: cfg.api_url, current_team: current, teams: rest });
|
|
158
|
+
removed = [t.name];
|
|
159
|
+
if (current && current !== cfg.current_team) process.stderr.write(`Now on ${rest[current].name || current}.\n`);
|
|
160
|
+
}
|
|
161
|
+
process.stderr.write(removed.length ? `Forgot the key for ${removed.join(', ')}.\n` : 'Removed the saved key.\n');
|
|
162
|
+
print({ ok: true, removed }, flags);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
module.exports = { login, whoami, logout, teams, switch: switchTeam };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print } = require('../output');
|
|
6
|
+
const { buildSpec } = require('./traces');
|
|
7
|
+
const { windowOf } = require('./runs');
|
|
8
|
+
const { usage, listQuery, pages, unitVerdicts } = require('../common');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* trodo conversations list [filter flags] [--search TEXT] [--limit 50] [--offset N]
|
|
12
|
+
* A conversation (a thread) matches when any of its runs does; eval conditions
|
|
13
|
+
* are decided for the whole conversation. --search matches the id or the user.
|
|
14
|
+
*/
|
|
15
|
+
async function list(_args, flags) {
|
|
16
|
+
const api = client(config.resolve(flags));
|
|
17
|
+
const spec = await buildSpec(api, flags, 'runs');
|
|
18
|
+
const q = listQuery(spec, windowOf);
|
|
19
|
+
if (flags.search && flags.search !== true) q.q = String(flags.search);
|
|
20
|
+
const rows = await pages(flags, async (limit, offset) => { const out = await api.get('/runs/conversations', { ...q, limit, offset }); return out.conversations || []; });
|
|
21
|
+
print(rows, flags, [['conversation_id', 'Conversation', 40], ['distinct_id', 'User', 26], ['run_count', 'Runs', 5], ['error_count', 'Errors', 6], ['last_run_at', 'Last run', 24]]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* trodo conversations get ID [--messages]: the thread, every run in it in order,
|
|
26
|
+
* and its evaluations. --messages prints the thread as user / assistant turns.
|
|
27
|
+
*/
|
|
28
|
+
async function get([id], flags) {
|
|
29
|
+
if (!id) throw usage('Usage: trodo conversations get CONVERSATION_ID [--messages]');
|
|
30
|
+
const api = client(config.resolve(flags));
|
|
31
|
+
const [detail, verdicts] = await Promise.all([
|
|
32
|
+
api.get(`/runs/conversations/${encodeURIComponent(id)}`),
|
|
33
|
+
api.get(`/eval3/units/conversation/${encodeURIComponent(id)}/verdicts`).catch(() => null),
|
|
34
|
+
]);
|
|
35
|
+
if (flags.messages) {
|
|
36
|
+
const turns = [];
|
|
37
|
+
for (const r of detail.runs || []) {
|
|
38
|
+
const input = r.input ?? r.input_preview ?? null;
|
|
39
|
+
const output = r.output ?? r.output_preview ?? null;
|
|
40
|
+
if (input != null) turns.push({ role: 'user', content: input, run_id: r.run_id, at: r.started_at });
|
|
41
|
+
if (output != null) turns.push({ role: 'assistant', content: output, run_id: r.run_id, at: r.ended_at || r.started_at });
|
|
42
|
+
}
|
|
43
|
+
print({ conversation_id: id, messages: turns }, flags);
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
print({ ...detail, evaluations: unitVerdicts(verdicts) }, flags);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { list, get };
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const config = require('../config');
|
|
4
|
+
const { client } = require('../http');
|
|
5
|
+
const { print, sink, readRows } = require('../output');
|
|
6
|
+
const { buildSpec } = require('./traces');
|
|
7
|
+
|
|
8
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
9
|
+
const usage = (msg) => { const e = new Error(msg); e.exitCode = 2; return e; };
|
|
10
|
+
|
|
11
|
+
async function resolveDataset(api, ref) {
|
|
12
|
+
if (!ref) throw usage('Name the dataset (its name or id).');
|
|
13
|
+
const { datasets } = await api.get('/datasets');
|
|
14
|
+
const all = datasets || [];
|
|
15
|
+
const hit = all.find((d) => d.id === ref) || all.find((d) => d.name === ref) || all.find((d) => d.name.toLowerCase() === String(ref).toLowerCase());
|
|
16
|
+
if (!hit) throw usage(`No dataset "${ref}". \`trodo datasets list\` shows them.`);
|
|
17
|
+
return hit;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function listDatasets(_a, flags) {
|
|
21
|
+
const { datasets } = await client(config.resolve(flags)).get('/datasets');
|
|
22
|
+
print(datasets || [], flags, [['name', 'Dataset', 40], ['item_count', 'Rows', 8], ['expected_count', 'With Expected', 14], ['id', 'Id', 36], ['updated_at', 'Updated', 24]]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function getDataset([ref], flags) {
|
|
26
|
+
const api = client(config.resolve(flags));
|
|
27
|
+
const d = await resolveDataset(api, ref);
|
|
28
|
+
const [sources, versions] = await Promise.all([
|
|
29
|
+
api.get(`/datasets/${d.id}/sources`).catch(() => ({ sources: [] })),
|
|
30
|
+
api.get(`/datasets/${d.id}/versions`).catch(() => ({ versions: [] })),
|
|
31
|
+
]);
|
|
32
|
+
print({ ...d, sources: sources.sources || sources, versions: (versions.versions || versions || []).slice(0, 20) }, flags);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* trodo datasets create NAME [--description D] [-f rows.jsonl]
|
|
37
|
+
* [--kind spans|runs|conversations|verdicts <filter flags> [--live] [--include-trace]]
|
|
38
|
+
* With --kind, the dataset starts with a source: what the filter matches is copied now
|
|
39
|
+
* (and, with --live, every 15 minutes after).
|
|
40
|
+
*/
|
|
41
|
+
async function create([name], flags) {
|
|
42
|
+
if (!name) throw usage('Usage: trodo datasets create NAME [--kind runs --eval "Grounded:passed" --include-trace]');
|
|
43
|
+
const api = client(config.resolve(flags));
|
|
44
|
+
// The filter is checked before anything is made: a typo never leaves an empty dataset behind.
|
|
45
|
+
const spec = flags.kind ? await buildSpec(api, flags, String(flags.kind)) : null;
|
|
46
|
+
// So is the file: read and parsed before the dataset exists.
|
|
47
|
+
const rows = flags.file && flags.file !== true ? await readRows(String(flags.file)) : null;
|
|
48
|
+
const { dataset } = await api.post('/datasets', { name, ...(flags.description ? { description: String(flags.description) } : {}) });
|
|
49
|
+
let source = null;
|
|
50
|
+
if (spec) source = await api.post(`/datasets/${dataset.id}/sources`, { ...spec, live: Boolean(flags.live) });
|
|
51
|
+
const added = rows && rows.length ? await appendRows(api, dataset, rows, flags) : null;
|
|
52
|
+
print({ dataset: { id: dataset.id, name: dataset.name }, ...(source ? { source: source.source, run: source.feed } : {}), ...(added ? { rows: added } : {}) }, flags);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** trodo datasets add DATASET --kind KIND <filter flags> [--live] [--include-trace]: another source. */
|
|
56
|
+
async function addSource([ref], flags) {
|
|
57
|
+
const api = client(config.resolve(flags));
|
|
58
|
+
const d = await resolveDataset(api, ref);
|
|
59
|
+
const spec = await buildSpec(api, flags, String(flags.kind || 'runs'));
|
|
60
|
+
const out = await api.post(`/datasets/${d.id}/sources`, { ...spec, live: Boolean(flags.live) });
|
|
61
|
+
print({ dataset: d.name, source: out.source, run: out.feed }, flags);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* trodo datasets append DATASET [-f rows.jsonl | stdin] [--include-trace]
|
|
66
|
+
* A line is a plain row { input | messages, output?, expected?, metadata? } or a
|
|
67
|
+
* reference to traffic ({ run_id } / { span_id } / { conversation_id } / { eval_run_id }),
|
|
68
|
+
* which the server fills with Input, Output and the evaluations. Rows already held are skipped.
|
|
69
|
+
*/
|
|
70
|
+
async function append([ref], flags) {
|
|
71
|
+
const api = client(config.resolve(flags));
|
|
72
|
+
const d = await resolveDataset(api, ref);
|
|
73
|
+
const rows = await readRows(flags.file);
|
|
74
|
+
print({ dataset: d.name, ...(await appendRows(api, d, rows, flags)) }, flags);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Rows (plain or references to traffic) into dataset `d`; returns { written, skipped }. */
|
|
78
|
+
async function appendRows(api, d, rows, flags) {
|
|
79
|
+
const REF = new Set(['run_id', 'span_id', 'conversation_id', 'eval_run_id']);
|
|
80
|
+
const refs = { run_ids: [], span_ids: [], conversation_ids: [], eval_run_ids: [] };
|
|
81
|
+
const plain = [];
|
|
82
|
+
for (const r of rows) {
|
|
83
|
+
const keys = r && typeof r === 'object' ? Object.keys(r) : [];
|
|
84
|
+
if (keys.length && keys.every((k) => REF.has(k))) {
|
|
85
|
+
if (r.eval_run_id) refs.eval_run_ids.push(String(r.eval_run_id));
|
|
86
|
+
else if (r.span_id) refs.span_ids.push(String(r.span_id));
|
|
87
|
+
else if (r.run_id) refs.run_ids.push(String(r.run_id));
|
|
88
|
+
else refs.conversation_ids.push(String(r.conversation_id));
|
|
89
|
+
} else if (r && typeof r === 'object') {
|
|
90
|
+
const { expected, ...rest } = r;
|
|
91
|
+
plain.push(expected !== undefined && rest.expected_output === undefined ? { ...rest, expected_output: expected } : rest);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let written = 0; let skipped = 0;
|
|
95
|
+
const chunk = (arr, n) => Array.from({ length: Math.ceil(arr.length / n) }, (_, i) => arr.slice(i * n, i * n + n));
|
|
96
|
+
for (const [key, ids] of Object.entries(refs)) {
|
|
97
|
+
for (const part of chunk(ids, 200)) {
|
|
98
|
+
// eslint-disable-next-line no-await-in-loop
|
|
99
|
+
const out = await api.post(`/datasets/${d.id}/items`, { [key]: part, ...(flags['include-trace'] ? { include_trace: true } : {}) });
|
|
100
|
+
written += Number(out.written ?? out.added ?? 0); skipped += Number(out.skipped ?? 0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const part of chunk(plain, 500)) {
|
|
104
|
+
// eslint-disable-next-line no-await-in-loop
|
|
105
|
+
const out = await api.post(`/datasets/${d.id}/items`, { items: part });
|
|
106
|
+
written += Number(out.written ?? out.added ?? (Array.isArray(out.items) ? out.items.length : part.length)); skipped += Number(out.skipped ?? 0);
|
|
107
|
+
}
|
|
108
|
+
return { written, skipped };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* trodo datasets update DATASET [-f updates.jsonl | stdin] [--label person|derived] [--name "version name"]
|
|
113
|
+
* Lines: { id, expected?, output?, metadata? } — the row ids an export or `rows` gave you.
|
|
114
|
+
* One version for the whole call. Expected counts as a person's unless --label derived.
|
|
115
|
+
*/
|
|
116
|
+
async function update([ref], flags) {
|
|
117
|
+
const api = client(config.resolve(flags));
|
|
118
|
+
const d = await resolveDataset(api, ref);
|
|
119
|
+
const rows = (await readRows(flags.file)).filter((r) => r && r.id);
|
|
120
|
+
if (!rows.length) throw usage('No updates: give lines like {"id": "…", "expected": "…"}.');
|
|
121
|
+
let updated = 0; let unchanged = 0; const missing = []; let version = null;
|
|
122
|
+
for (let i = 0; i < rows.length; i += 5000) {
|
|
123
|
+
// eslint-disable-next-line no-await-in-loop
|
|
124
|
+
const out = await api.post(`/datasets/${d.id}/items/update`, {
|
|
125
|
+
updates: rows.slice(i, i + 5000),
|
|
126
|
+
label: flags.label === 'derived' ? 'derived' : 'person',
|
|
127
|
+
...(flags.name ? { version_name: String(flags.name) } : {}),
|
|
128
|
+
});
|
|
129
|
+
updated += out.updated; unchanged += out.unchanged; missing.push(...(out.missing || [])); version = out.version || version;
|
|
130
|
+
}
|
|
131
|
+
print({ dataset: d.name, updated, unchanged, missing: missing.slice(0, 50), version }, flags);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* trodo datasets export DATASET [--rows all|expected|no-expected|passed|failed|marked-wrong]
|
|
136
|
+
* [--eval NAME] [--min-score N] [--format trodo|openai|dpo] [--target expected|output|expected-else-output]
|
|
137
|
+
* [--version N] [-o file]
|
|
138
|
+
* `--rows passed --format openai --target output` is a supervised fine-tuning file
|
|
139
|
+
* made of what went well, no labeling needed.
|
|
140
|
+
*/
|
|
141
|
+
async function exportDataset([ref], flags) {
|
|
142
|
+
const api = client(config.resolve(flags));
|
|
143
|
+
const d = await resolveDataset(api, ref);
|
|
144
|
+
const query = {
|
|
145
|
+
rows: flags.rows ? String(flags.rows).replace(/-/g, '_') : undefined,
|
|
146
|
+
eval: flags.eval ? String(flags.eval) : undefined,
|
|
147
|
+
min_score: flags['min-score'],
|
|
148
|
+
format: flags.format ? String(flags.format) : undefined,
|
|
149
|
+
target: flags.target ? String(flags.target).replace(/-/g, '_') : undefined,
|
|
150
|
+
version: flags.version === true ? undefined : flags.version,
|
|
151
|
+
};
|
|
152
|
+
const res = await api.raw('GET', `/datasets/${d.id}/export`, { query });
|
|
153
|
+
if (!res.ok) {
|
|
154
|
+
const t = await res.text(); let body; try { body = JSON.parse(t); } catch { body = { message: t }; }
|
|
155
|
+
const e = new Error(body.message || body.error || `HTTP ${res.status}`); e.exitCode = 1; throw e;
|
|
156
|
+
}
|
|
157
|
+
const out = sink(flags.out && flags.out !== true ? String(flags.out) : null);
|
|
158
|
+
const decoder = new TextDecoder();
|
|
159
|
+
let buf = '';
|
|
160
|
+
for await (const c of res.body) {
|
|
161
|
+
buf += decoder.decode(c, { stream: true });
|
|
162
|
+
let i;
|
|
163
|
+
while ((i = buf.indexOf('\n')) >= 0) {
|
|
164
|
+
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
|
|
165
|
+
// eslint-disable-next-line no-await-in-loop
|
|
166
|
+
if (line) await out.write(JSON.parse(line));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (buf.trim()) await out.write(JSON.parse(buf.trim()));
|
|
170
|
+
await out.close();
|
|
171
|
+
const n = res.headers.get('x-trodo-export-rows'); const skipped = res.headers.get('x-trodo-export-skipped');
|
|
172
|
+
process.stderr.write(`${n} rows from ${d.name}${skipped ? `, ${skipped} skipped (no input or no target)` : ''}${flags.out ? ` → ${flags.out}` : ''}\n`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function versions([ref], flags) {
|
|
176
|
+
const api = client(config.resolve(flags));
|
|
177
|
+
const d = await resolveDataset(api, ref);
|
|
178
|
+
const out = await api.get(`/datasets/${d.id}/versions`);
|
|
179
|
+
print(out.versions || out, flags, [['version_no', 'v', 4], ['name', 'Name', 40], ['item_count', 'Rows', 8], ['created_at', 'At', 24]]);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
module.exports = { list: listDatasets, get: getDataset, create, add: addSource, append, update, export: exportDataset, rows: exportDataset, download: exportDataset, versions, resolveDataset, UUID };
|