trodo-cli 0.2.0 → 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.
@@ -4,9 +4,10 @@ const config = require('../config');
4
4
  const { client } = require('../http');
5
5
  const { print } = require('../output');
6
6
  const { buildSpec } = require('./traces');
7
+ const { usage, listQuery, pages, unitVerdicts, int } = require('../common');
7
8
 
8
9
  const DAY = 86400e3;
9
- /** A filter's time → { time_start, time_end } for the runs list. */
10
+ /** A filter's time → { time_start, time_end } for the lists. */
10
11
  function windowOf(time, now = Date.now()) {
11
12
  if (time.from || time.to) return { time_start: time.from || new Date(now - 7 * DAY).toISOString(), time_end: time.to || new Date(now).toISOString() };
12
13
  const p = String(time.preset || '7d');
@@ -19,23 +20,99 @@ function windowOf(time, now = Date.now()) {
19
20
  return { time_start: new Date(now - n * unit).toISOString(), time_end: new Date(now).toISOString() };
20
21
  }
21
22
 
22
- /** trodo runs list [filter flags] [--limit 50] */
23
+ /** trodo runs list [filter flags] [--limit 50] [--offset N] */
23
24
  async function listRuns(_args, flags) {
24
25
  const api = client(config.resolve(flags));
25
26
  const spec = await buildSpec(api, flags, 'runs');
26
- const mf = [...spec.filter.modularFilters];
27
- if (spec.filter.eval_conditions) mf.push({ category: 'evals', field: 'eval_conditions', operator: 'is', value: spec.filter.eval_conditions });
28
- const query = { ...windowOf(spec.filter.time), limit: spec.options.limit || 50 };
29
- // The runs list decodes this once more (decodeURIComponent then JSON), as the app sends it.
30
- if (mf.length) query.modularFilters = encodeURIComponent(JSON.stringify(mf));
31
- const out = await api.get('/runs', query);
32
- print(out.runs || out, flags, [['run_id', 'Run', 36], ['agent_name', 'Agent', 30], ['status', 'Status', 8], ['started_at', 'Started', 24], ['duration_ms', 'ms', 8]]);
27
+ const q = listQuery(spec, windowOf);
28
+ const rows = await pages(flags, async (limit, offset) => { const out = await api.get('/runs', { ...q, limit, offset }); return out.runs || []; }, { max: 100 });
29
+ print(rows, flags, [['run_id', 'Run', 36], ['agent_name', 'Agent', 30], ['status', 'Status', 8], ['started_at', 'Started', 24], ['duration_ms', 'ms', 8]]);
33
30
  }
34
31
 
35
- /** trodo runs get RUN_ID: the run and its spans. */
32
+ /** The spans of a run as an indented tree, one line each: what a person reads first. */
33
+ function spanTree(spans) {
34
+ const byId = new Map(spans.map((s) => [s.span_id, s]));
35
+ const kids = new Map();
36
+ const roots = [];
37
+ const order = (a, b) => String(a.started_at || '').localeCompare(String(b.started_at || ''));
38
+ for (const s of [...spans].sort(order)) {
39
+ if (s.parent_span_id && byId.has(s.parent_span_id)) {
40
+ if (!kids.has(s.parent_span_id)) kids.set(s.parent_span_id, []);
41
+ kids.get(s.parent_span_id).push(s);
42
+ } else roots.push(s);
43
+ }
44
+ const lines = [];
45
+ const label = (s) => {
46
+ const bits = [`${s.name || s.tool_name || s.kind}`, `[${s.kind || 'span'}]`];
47
+ if (s.model) bits.push(s.model);
48
+ if (s.duration_ms != null) bits.push(`${(Number(s.duration_ms) / 1000).toFixed(2)}s`);
49
+ const tok = (Number(s.input_tokens) || 0) + (Number(s.output_tokens) || 0);
50
+ if (tok) bits.push(`${tok} tok`);
51
+ if (s.status && s.status !== 'ok') bits.push(`✗ ${s.status}${s.error_message ? `: ${String(s.error_message).slice(0, 80)}` : ''}`);
52
+ bits.push(s.span_id);
53
+ return bits.join(' ');
54
+ };
55
+ const walk = (s, prefix, last, depth) => {
56
+ lines.push(`${depth ? `${prefix}${last ? '└─ ' : '├─ '}` : ''}${label(s)}`);
57
+ const c = kids.get(s.span_id) || [];
58
+ c.forEach((k, i) => walk(k, depth ? `${prefix}${last ? ' ' : '│ '}` : '', i === c.length - 1, depth + 1));
59
+ };
60
+ roots.forEach((r, i) => walk(r, '', i === roots.length - 1, 0));
61
+ return lines.join('\n');
62
+ }
63
+
64
+ /**
65
+ * trodo runs get RUN_ID [--tree]: the run, its spans, messages, feedback and
66
+ * evaluations. --tree prints the span tree instead of JSON.
67
+ */
36
68
  async function getRun([id], flags) {
37
- if (!id) { const e = new Error('Usage: trodo runs get RUN_ID'); e.exitCode = 2; throw e; }
38
- print(await client(config.resolve(flags)).get(`/runs/${encodeURIComponent(id)}`, { include: 'spans,messages' }), flags);
69
+ if (!id) throw usage('Usage: trodo runs get RUN_ID [--tree]');
70
+ const api = client(config.resolve(flags));
71
+ const [detail, verdicts] = await Promise.all([
72
+ api.get(`/runs/${encodeURIComponent(id)}`, { include: 'spans,messages' }),
73
+ api.get(`/eval3/units/run/${encodeURIComponent(id)}/verdicts`).catch(() => null),
74
+ ]);
75
+ if (flags.tree) {
76
+ const r = detail.run || {};
77
+ const head = `${r.agent_name || 'run'} ${r.status || ''} ${r.started_at || ''} ${r.duration_ms != null ? `${(r.duration_ms / 1000).toFixed(2)}s` : ''} ${r.run_id || id}`;
78
+ process.stdout.write(`${head}\n${spanTree(detail.spans || [])}\n`);
79
+ return;
80
+ }
81
+ print({ ...detail, evaluations: unitVerdicts(verdicts) }, flags);
82
+ }
83
+
84
+ /** trodo runs tree RUN_ID: the span tree alone. */
85
+ const tree = (args, flags) => getRun(args, { ...flags, tree: true });
86
+
87
+ /**
88
+ * trodo runs feedback RUN_ID [--thumbs up|down] [--rating 1-5] [--comment TEXT] [--user DISTINCT_ID]
89
+ * A person's feedback on the run, as the SDK and the run page record it.
90
+ */
91
+ async function feedback([id], flags) {
92
+ if (!id) throw usage('Usage: trodo runs feedback RUN_ID --thumbs up|down [--rating 1-5] [--comment TEXT]');
93
+ const body = {};
94
+ if (flags.thumbs !== undefined) {
95
+ const t = String(flags.thumbs);
96
+ if (!['up', 'down'].includes(t)) throw usage('--thumbs is up or down.');
97
+ body.satisfaction = t === 'up' ? 'positive' : 'negative';
98
+ }
99
+ if (flags.rating !== undefined) {
100
+ const n = Number(flags.rating);
101
+ if (!Number.isInteger(n) || n < 1 || n > 5) throw usage('--rating is a whole number from 1 to 5.');
102
+ body.rating = n;
103
+ }
104
+ if (flags.comment !== undefined && flags.comment !== true) body.comment = String(flags.comment);
105
+ if (flags.user !== undefined && flags.user !== true) body.distinct_id = String(flags.user);
106
+ if (!Object.keys(body).some((k) => ['satisfaction', 'rating', 'comment'].includes(k))) throw usage('Give --thumbs, --rating or --comment.');
107
+ print(await client(config.resolve(flags)).post(`/runs/${encodeURIComponent(id)}/feedback`, body), flags);
108
+ }
109
+
110
+ /** trodo runs search TEXT [--limit 50]: runs whose input, output or spans mention TEXT. */
111
+ async function search(words, flags) {
112
+ const q = words.join(' ').trim();
113
+ if (!q) throw usage('Usage: trodo runs search TEXT [--limit 50]');
114
+ const out = await client(config.resolve(flags)).get('/runs/search', { q, limit: int(flags.limit, 50, 200) });
115
+ print(out.runs || out, flags, [['run_id', 'Run', 36], ['agent_name', 'Agent', 30], ['status', 'Status', 8], ['started_at', 'Started', 24], ['distinct_id', 'User', 26]]);
39
116
  }
40
117
 
41
- module.exports = { list: listRuns, get: getRun, windowOf };
118
+ module.exports = { list: listRuns, get: getRun, tree, feedback, search, windowOf, spanTree };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { print } = require('../output');
7
+ const { usage } = require('../common');
8
+
9
+ const SKILLS_DIR = path.join(__dirname, '..', '..', 'skills');
10
+
11
+ /**
12
+ * trodo setup claude [--global] | trodo setup --dir DIR
13
+ * Installs the skills this CLI ships (trodo: read and act on traces; trodo-datasets:
14
+ * training data) where a coding agent finds them: ./.claude/skills (this project),
15
+ * ~/.claude/skills with --global, or any folder with --dir.
16
+ */
17
+ async function setup([agent], flags) {
18
+ let root;
19
+ if (flags.dir && flags.dir !== true) root = path.resolve(String(flags.dir));
20
+ else if (agent === 'claude') root = flags.global ? path.join(os.homedir(), '.claude', 'skills') : path.resolve('.claude', 'skills');
21
+ else throw usage('Usage: trodo setup claude [--global] or trodo setup --dir SKILLS_DIR');
22
+ const installed = [];
23
+ for (const name of fs.readdirSync(SKILLS_DIR)) {
24
+ const src = path.join(SKILLS_DIR, name, 'SKILL.md');
25
+ if (!fs.existsSync(src)) continue;
26
+ const dest = path.join(root, name);
27
+ fs.mkdirSync(dest, { recursive: true });
28
+ fs.copyFileSync(src, path.join(dest, 'SKILL.md'));
29
+ installed.push(path.join(dest, 'SKILL.md'));
30
+ }
31
+ print({ installed }, flags);
32
+ }
33
+
34
+ module.exports = { setup };
@@ -0,0 +1,38 @@
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, int } = require('../common');
9
+
10
+ /** trodo spans list [filter flags] [--limit 50] [--offset N]: newest first. */
11
+ async function list(_args, flags) {
12
+ const api = client(config.resolve(flags));
13
+ const spec = await buildSpec(api, flags, 'spans');
14
+ const q = listQuery(spec, windowOf);
15
+ const rows = await pages(flags, async (limit, offset) => { const out = await api.get('/spans', { ...q, limit, offset }); return out.spans || []; });
16
+ print(rows, flags, [['span_id', 'Span', 36], ['name', 'Name', 28], ['kind', 'Kind', 8], ['model', 'Model', 22], ['status', 'Status', 8], ['duration_ms', 'ms', 8], ['run_id', 'Run', 36]]);
17
+ }
18
+
19
+ /** trodo spans get SPAN_ID: the span with its full input and output, and its evaluations. */
20
+ async function get([id], flags) {
21
+ if (!id) throw usage('Usage: trodo spans get SPAN_ID');
22
+ const api = client(config.resolve(flags));
23
+ const [detail, verdicts] = await Promise.all([
24
+ api.get(`/spans/${encodeURIComponent(id)}`),
25
+ api.get(`/eval3/units/span/${encodeURIComponent(id)}/verdicts`).catch(() => null),
26
+ ]);
27
+ print({ ...detail, evaluations: unitVerdicts(verdicts) }, flags);
28
+ }
29
+
30
+ /** trodo spans search TEXT [--limit 50]: spans whose name, input or output mention TEXT. */
31
+ async function search(words, flags) {
32
+ const q = words.join(' ').trim();
33
+ if (!q) throw usage('Usage: trodo spans search TEXT [--limit 50]');
34
+ const out = await client(config.resolve(flags)).get('/spans/search', { q, limit: int(flags.limit, 50, 200) });
35
+ print(out.spans || out, flags, [['span_id', 'Span', 36], ['name', 'Name', 28], ['kind', 'Kind', 8], ['started_at', 'Started', 24], ['run_id', 'Run', 36]]);
36
+ }
37
+
38
+ module.exports = { list, get, search };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
3
5
  const config = require('../config');
4
6
  const { client } = require('../http');
5
7
  const { print, sink } = require('../output');
@@ -32,15 +34,79 @@ async function exportTraces(_args, flags) {
32
34
  const body = { ...spec };
33
35
  if (flags.format) body.format = String(flags.format);
34
36
  if (flags.target) body.target = String(flags.target);
35
- const out = sink(flags.out && flags.out !== true ? String(flags.out) : null);
37
+ const dir = flags.dir && flags.dir !== true ? String(flags.dir) : null;
38
+ if (dir && flags.out) throw Object.assign(new Error('Give -o FILE or --dir DIR, not both.'), { exitCode: 2 });
39
+ if (dir) fs.mkdirSync(dir, { recursive: true });
40
+ const out = dir ? null : sink(flags.out && flags.out !== true ? String(flags.out) : null);
36
41
  let summary = null;
42
+ let n = 0;
37
43
  for await (const line of api.lines('POST', '/traces/export', { body })) {
38
44
  if (line && line._trodo) { summary = line._trodo; continue; }
45
+ n += 1;
46
+ if (dir) {
47
+ // One file per unit, named by what it is: run-<id>.json, span-<id>.json, conversation-<id>.json.
48
+ const src = line.source || {};
49
+ const kind = line.unit_kind || (spec.kind === 'eval_results' ? 'verdict' : spec.kind.replace(/s$/, ''));
50
+ const md = line.metadata || {};
51
+ const byKind = { span: src.span_id || md.span_id, conversation: src.conversation_id || md.conversation_id, verdict: md.eval_run_id || src.eval_run_id, run: src.run_id || md.run_id };
52
+ const id = byKind[kind] || src.span_id || src.run_id || src.conversation_id || String(n).padStart(5, '0');
53
+ fs.writeFileSync(path.join(dir, `${kind}-${String(id).replace(/[^\w.-]/g, '_')}.json`), `${JSON.stringify(line, null, 2)}\n`);
54
+ continue;
55
+ }
39
56
  // eslint-disable-next-line no-await-in-loop
40
57
  await out.write(line);
41
58
  }
42
- await out.close();
43
- if (summary) process.stderr.write(`${summary.rows} ${spec.kind}${summary.skipped ? `, ${summary.skipped} skipped (no input or no target)` : ''}${flags.out ? ` → ${flags.out}` : ''}\n`);
59
+ if (out) await out.close();
60
+ if (summary) process.stderr.write(`${summary.rows} ${spec.kind}${summary.skipped ? `, ${summary.skipped} skipped (no input or no target)` : ''}${flags.out ? ` → ${flags.out}` : dir ? ` → ${dir}/` : ''}\n`);
44
61
  }
45
62
 
46
- module.exports = { count, export: exportTraces, buildSpec };
63
+ /**
64
+ * trodo traces stats [filter flags] [--compare]
65
+ * Count, error rate, latency p50/p95/p99, tokens, cost, feedback, eval
66
+ * outcomes and the busiest agents for what the filter matches. --compare puts
67
+ * the window just before it beside it (the last 7 days against the 7 before).
68
+ */
69
+ async function stats(_args, flags) {
70
+ const api = client(config.resolve(flags));
71
+ const spec = await buildSpec(api, flags, flags.kind || 'runs');
72
+ const current = await api.post('/traces/stats', spec);
73
+ if (!flags.compare) { print(current, flags); return; }
74
+ const from = new Date(current.window.from).getTime();
75
+ const to = new Date(current.window.to).getTime();
76
+ const prevSpec = { ...spec, filter: { ...spec.filter, time: { from: new Date(from - (to - from)).toISOString(), to: new Date(from).toISOString() } } };
77
+ const previous = await api.post('/traces/stats', prevSpec);
78
+ const pct = (a, b) => (b ? Number((((a - b) / b) * 100).toFixed(1)) : null);
79
+ print({
80
+ current,
81
+ previous,
82
+ change_pct: {
83
+ units: pct(current.units, previous.units),
84
+ error_rate: current.error_rate != null && previous.error_rate != null ? Number(((current.error_rate - previous.error_rate) * 100).toFixed(2)) : null,
85
+ p50_ms: pct(current.latency_ms.p50, previous.latency_ms.p50),
86
+ p95_ms: pct(current.latency_ms.p95, previous.latency_ms.p95),
87
+ cost: pct(current.cost, previous.cost),
88
+ },
89
+ }, flags);
90
+ }
91
+
92
+ /** trodo traces agents: the agent names traces carry (what --agent takes), with run counts. */
93
+ async function agents(_a, flags) {
94
+ const out = await client(config.resolve(flags)).get('/runs/agents');
95
+ print(out.agents || out, flags, [['agent_name', 'Agent', 40], ['run_count', 'Runs', 8], ['error_count', 'Errors', 8], ['total_cost', 'Cost', 12], ['last_seen_at', 'Last seen', 24]]);
96
+ }
97
+
98
+ /** trodo traces fields [--agent NAME]: the properties --where can name, run and span, fixed and custom. */
99
+ async function fields(_a, flags) {
100
+ const agent = flags.agent && flags.agent !== true ? [].concat(flags.agent)[0] : undefined;
101
+ print(await client(config.resolve(flags)).get('/runs/property-keys', { agent_name: agent }), flags);
102
+ }
103
+
104
+ /** trodo traces values FIELD [--search TEXT] [--limit 50]: the values one property takes, most common first. */
105
+ async function values([field], flags) {
106
+ if (!field) throw Object.assign(new Error('Usage: trodo traces values FIELD [--search TEXT] (trodo traces fields lists them)'), { exitCode: 2 });
107
+ const agent = flags.agent && flags.agent !== true ? [].concat(flags.agent)[0] : undefined;
108
+ const out = await client(config.resolve(flags)).get('/runs/property-values', { field, q: flags.search !== true ? flags.search : undefined, agent_name: agent, limit: flags.limit });
109
+ print(out.values || out, flags, [['value', 'Value', 50], ['count', 'Count', 10]]);
110
+ }
111
+
112
+ module.exports = { count, export: exportTraces, stats, agents, fields, values, buildSpec };
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ const config = require('../config');
4
+ const { client } = require('../http');
5
+ const { print } = require('../output');
6
+ const { usage } = require('../common');
7
+
8
+ /** trodo users get DISTINCT_ID [--since 30d]: one user's runs, conversations, cost, evals and feedback, as the user drawer shows. */
9
+ async function get([id], flags) {
10
+ if (!id) throw usage('Usage: trodo users get DISTINCT_ID');
11
+ print(await client(config.resolve(flags)).get(`/runs/users/${encodeURIComponent(id)}/profile`, { period: flags.since !== true ? flags.since : undefined }), flags);
12
+ }
13
+
14
+ module.exports = { get };
package/lib/common.js ADDED
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+
3
+ /** Small pieces every command uses. */
4
+
5
+ const usage = (msg) => { const e = new Error(msg); e.exitCode = 2; return e; };
6
+
7
+ /** The one named `ref` (an id, an exact name, or a name in any case) out of `all`. */
8
+ function pick(all, ref, { kind, listCmd, nameKey = 'name', idKeys = ['id'] } = {}) {
9
+ if (ref == null || ref === '' || ref === true) throw usage(`Name the ${kind} (its name or id).`);
10
+ const r = String(ref);
11
+ const hit = all.find((x) => idKeys.some((k) => x[k] != null && String(x[k]) === r))
12
+ || all.find((x) => x[nameKey] === r)
13
+ || all.find((x) => String(x[nameKey] || '').toLowerCase() === r.toLowerCase());
14
+ if (!hit) throw usage(`No ${kind} "${r}". \`${listCmd}\` shows them.`);
15
+ return hit;
16
+ }
17
+
18
+ /**
19
+ * A filter spec (filters.specFromFlags) as the query the Runs, Spans and
20
+ * Conversations lists read: the time window plus the Traces filter chips,
21
+ * eval conditions included, sent the way the app sends them.
22
+ */
23
+ function listQuery(spec, windowOf) {
24
+ const mf = [...(spec.filter.modularFilters || [])];
25
+ if (spec.filter.eval_conditions) mf.push({ category: 'evals', field: 'eval_conditions', operator: 'is', value: spec.filter.eval_conditions });
26
+ const q = { ...windowOf(spec.filter.time) };
27
+ // The lists decode this once more (decodeURIComponent then JSON), as the app sends it.
28
+ if (mf.length) q.modularFilters = encodeURIComponent(JSON.stringify(mf));
29
+ return q;
30
+ }
31
+
32
+ const int = (v, dflt, max) => {
33
+ const n = Number.parseInt(v, 10);
34
+ if (!Number.isFinite(n) || n < 1) return dflt;
35
+ return max ? Math.min(n, max) : n;
36
+ };
37
+
38
+ /**
39
+ * One page, or with --all every page (`max` at a time, up to --max-items,
40
+ * 10,000 by default): `page(limit, offset)` returns that page's rows.
41
+ */
42
+ async function pages(flags, page, { dflt = 50, max = 200 } = {}) {
43
+ if (!flags.all) return page(int(flags.limit, dflt, max), flags.offset);
44
+ const cap = int(flags['max-items'], 10000, 1000000);
45
+ const out = [];
46
+ let offset = Number.parseInt(flags.offset, 10) || 0;
47
+ for (;;) {
48
+ // eslint-disable-next-line no-await-in-loop
49
+ const rows = await page(max, offset);
50
+ out.push(...rows);
51
+ if (rows.length < max || out.length >= cap) break;
52
+ offset += rows.length;
53
+ }
54
+ return out.slice(0, cap);
55
+ }
56
+
57
+ /**
58
+ * Every eval's verdicts on one unit (GET /eval3/units/:target/:id/verdicts,
59
+ * grouped by eval) as one list, newest first, each carrying its eval's name.
60
+ */
61
+ function unitVerdicts(out) {
62
+ if (!out) return undefined;
63
+ if (Array.isArray(out.verdicts)) return out.verdicts;
64
+ const rows = [];
65
+ for (const l of out.lineages || []) for (const v of l.verdicts || []) rows.push({ lineage_name: l.name, ...v });
66
+ return rows.sort((a, b) => String(b.created_at || '').localeCompare(String(a.created_at || '')));
67
+ }
68
+
69
+ module.exports = { usage, pick, listQuery, int, pages, unitVerdicts };
package/lib/config.js CHANGED
@@ -3,8 +3,13 @@
3
3
  /**
4
4
  * Where the CLI finds its key and server: TRODO_API_KEY / TRODO_API_URL, else
5
5
  * ~/.trodo/config.json (written by `trodo login`, mode 600). A key is a Trodo
6
- * API key (trodo_mk_…, Settings → MCP); it acts as the person who made
7
- * it, on its team, with its scopes.
6
+ * API key (trodo_mk_…, Settings → MCP); it acts as the person who made it, on
7
+ * its team, with its scopes.
8
+ *
9
+ * A key belongs to one team, so the file keeps one per team signed in to:
10
+ * { "current_team": "12", "teams": { "12": { "name", "api_key", "api_url" }, … } }
11
+ * `trodo switch TEAM` changes the current one; `--team TEAM` picks one for a
12
+ * single command. A file from 0.2 ({ api_key, api_url }) still works.
8
13
  */
9
14
  const fs = require('fs');
10
15
  const os = require('os');
@@ -24,14 +29,50 @@ function write(cfg) {
24
29
  const f = file();
25
30
  fs.mkdirSync(path.dirname(f), { recursive: true, mode: 0o700 });
26
31
  fs.writeFileSync(f, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
32
+ // `mode` applies only when the file is created: an older file keeps whatever it had.
33
+ try { fs.chmodSync(f, 0o600); } catch { /* not ours to change (a shared mount); the key is still written */ }
27
34
  return f;
28
35
  }
29
36
 
37
+ /** The saved teams as a list, the current one marked. */
38
+ function teams(cfg = read()) {
39
+ return Object.entries(cfg.teams || {}).map(([id, t]) => ({ id, name: t.name || id, api_url: t.api_url || cfg.api_url || DEFAULT_URL, current: String(cfg.current_team) === id, api_key: t.api_key }));
40
+ }
41
+
42
+ /** A saved team by id or name (exact, then any case); null when none. */
43
+ function findTeam(cfg, ref) {
44
+ const all = teams(cfg);
45
+ const r = String(ref);
46
+ return all.find((t) => t.id === r) || all.find((t) => t.name === r) || all.find((t) => t.name.toLowerCase() === r.toLowerCase()) || null;
47
+ }
48
+
30
49
  function resolve(flags = {}) {
31
50
  const cfg = read();
32
- const key = flags.key || process.env.TRODO_API_KEY || cfg.api_key || null;
33
- const url = String(flags.url || process.env.TRODO_API_URL || cfg.api_url || DEFAULT_URL).replace(/\/+$/, '');
34
- return { key, url };
51
+ const envUrl = flags.url || process.env.TRODO_API_URL;
52
+ const finish = (key, url) => ({ key, url: String(envUrl || url || cfg.api_url || DEFAULT_URL).replace(/\/+$/, '') });
53
+ if (flags.key && flags.key !== true) return finish(String(flags.key));
54
+ if (process.env.TRODO_API_KEY) return finish(process.env.TRODO_API_KEY);
55
+ if (flags.team && flags.team !== true) {
56
+ const t = findTeam(cfg, flags.team);
57
+ if (!t) {
58
+ const e = new Error(`Not signed in to a team called "${flags.team}". \`trodo teams\` lists yours; \`trodo login\` adds one.`);
59
+ e.exitCode = 2;
60
+ throw e;
61
+ }
62
+ return finish(t.api_key, t.api_url);
63
+ }
64
+ const cur = cfg.teams && cfg.current_team != null ? cfg.teams[String(cfg.current_team)] : null;
65
+ if (cur) return finish(cur.api_key, cur.api_url);
66
+ return finish(cfg.api_key || null);
67
+ }
68
+
69
+ /** Save a team's key and make it the current team. */
70
+ function saveTeam({ id, name, key, url }) {
71
+ const cfg = read();
72
+ const t = { ...(cfg.teams || {}) };
73
+ t[String(id)] = { name, api_key: key, api_url: url };
74
+ const next = { api_url: url, current_team: String(id), teams: t };
75
+ return write(next);
35
76
  }
36
77
 
37
- module.exports = { read, write, resolve, file, DEFAULT_URL };
78
+ module.exports = { read, write, resolve, file, teams, findTeam, saveTeam, DEFAULT_URL };
package/lib/http.js CHANGED
@@ -18,7 +18,7 @@ function client({ url, key, fetchImpl = globalThis.fetch }) {
18
18
  throw e;
19
19
  }
20
20
  const base = `${url}/api/v1`;
21
- const headers = { Authorization: `Bearer ${key}`, 'User-Agent': 'trodo-cli/0.2', Accept: 'application/json' };
21
+ const headers = { Authorization: `Bearer ${key}`, 'User-Agent': `trodo-cli/${require('../package.json').version}`, Accept: 'application/json' };
22
22
 
23
23
  async function request(method, p, { query, body } = {}) {
24
24
  const qs = query ? `?${new URLSearchParams(Object.entries(query).filter(([, v]) => v !== undefined && v !== null && v !== '').map(([k, v]) => [k, String(v)]))}` : '';
@@ -65,6 +65,7 @@ function client({ url, key, fetchImpl = globalThis.fetch }) {
65
65
  get: (p, query) => json('GET', p, { query }),
66
66
  post: (p, body, query) => json('POST', p, { body, query }),
67
67
  patch: (p, body) => json('PATCH', p, { body }),
68
+ del: (p, query) => json('DELETE', p, { query }),
68
69
  lines,
69
70
  raw: request,
70
71
  };
package/lib/main.js CHANGED
@@ -9,31 +9,64 @@ const { ApiError } = require('./http');
9
9
 
10
10
  const pkg = require('../package.json');
11
11
 
12
- const HELP = `trodo ${pkg.version} — traces, datasets and agents from the terminal
12
+ const HELP = `trodo ${pkg.version} — traces, evals, datasets, prompts and agents from the terminal
13
13
 
14
14
  trodo login [--url https://api.trodo.ai] [--no-browser] sign in through the app
15
15
  trodo login --key trodo_mk_… or with a key (CI)
16
- trodo whoami | logout
16
+ trodo whoami
17
+ trodo teams the teams this terminal is signed in to
18
+ trodo switch TEAM use another one (login adds teams; --team T for one command)
19
+ trodo logout [TEAM] [--all]
17
20
 
18
- trodo evals list eval names and their steps, for --eval
19
- trodo traces count [filter]
20
- trodo traces export [filter] [--include-trace] [--format trodo|openai|dpo] [--target output|expected] [-o file]
21
- trodo runs list [filter] [--limit 50]
22
- trodo runs get RUN_ID
21
+ Traces
22
+ trodo traces count [filter] how many runs / spans / conversations / verdicts match
23
+ trodo traces stats [filter] [--compare] errors, latency, tokens, cost, feedback, evals, by agent
24
+ trodo traces agents | fields | values FIELD what --agent and --where can name
25
+ trodo traces export [filter] [--include-trace] [--format trodo|openai|dpo] [--target output|expected]
26
+ [-o file | --dir DIR] JSONL, or one JSON file per run / span / conversation
27
+ trodo runs list [filter] [--limit 50] [--offset N]
28
+ trodo runs search TEXT runs whose input, output or spans mention TEXT
29
+ trodo runs get RUN_ID [--tree] the run, spans, messages, feedback, evaluations
30
+ trodo runs tree RUN_ID the span tree
31
+ trodo runs feedback RUN_ID [--thumbs up|down] [--rating 1-5] [--comment TEXT]
32
+ trodo spans list [filter] | search TEXT | get SPAN_ID
33
+ trodo conversations list [filter] [--search TEXT] | get ID [--messages] (alias: threads)
34
+ trodo users get DISTINCT_ID one user's runs, cost, evals and feedback
23
35
 
36
+ Evals
37
+ trodo evals list | get EVAL names, steps (STEP.FIELD), 30-day stats
38
+ trodo evals steps EVAL [--since 30d] [--table] the paths verdicts took through the steps, with outcomes
39
+ trodo evals verdicts EVAL [--failed|--passed] [--since 7d] [--version N]
40
+ trodo evals verdicts --run ID | --span ID | --conversation ID
41
+ trodo evals verdict VERDICT_ID every step and why
42
+ trodo evals mark VERDICT_ID --right | --wrong --should pass|fail --reason TEXT
43
+ trodo evals queue [--eval EVAL] | review ITEM_ID | grade ITEM_ID CHOICE verdicts waiting on a person
44
+
45
+ Datasets
24
46
  trodo datasets list | get DATASET | versions DATASET
25
- trodo datasets create NAME [--kind runs|spans|conversations|verdicts [filter] [--live] [--include-trace]]
47
+ trodo datasets create NAME [-f rows.jsonl] [--kind runs|spans|conversations|verdicts [filter] [--live] [--include-trace]]
26
48
  trodo datasets add DATASET --kind KIND [filter] [--live] [--include-trace]
27
- trodo datasets append DATASET [-f rows.jsonl] rows, or {run_id}/{span_id}/{conversation_id} lines
49
+ trodo datasets append DATASET [-f rows.jsonl] {input, output?, expected?} or {run_id}/{span_id}/{conversation_id} lines
28
50
  trodo datasets update DATASET [-f updates.jsonl] {id, expected?, output?, metadata?} lines, one version
29
51
  trodo datasets export DATASET [--rows passed|failed|expected|no-expected|marked-wrong|all]
30
52
  [--eval NAME] [--min-score N] [--format trodo|openai|dpo] [--target ...] [--version N] [-o file]
31
53
 
32
- trodo agents list | executions AGENT | execution ID
54
+ Prompts
55
+ trodo prompts list | get PROMPT [--version N | --label L] | versions PROMPT
56
+ trodo prompts pull PROMPT [--label production] [-o prompt.json]
57
+ trodo prompts push PROMPT -f prompt.json [--message "why"] [--label staging]
58
+ trodo prompts label PROMPT VERSION LABEL [--remove]
59
+
60
+ Issues and agents
61
+ trodo issues list [--status open,regression] | get ID | failures ID | close ID | reopen ID
62
+ trodo agents list | get AGENT | executions AGENT | execution ID
33
63
  trodo agents run AGENT [--input JSON] [--wait]
34
64
 
35
- Filter (traces, runs, datasets create/add):
36
- --kind spans|runs|conversations|verdicts (default runs)
65
+ trodo api GET|POST|PATCH|DELETE /path [--param k=v] [--data JSON] any /api/v1 route
66
+ trodo setup claude [--global] install the Trodo skills for Claude Code (or --dir DIR)
67
+
68
+ Filter (traces, runs, spans, conversations, datasets create/add):
69
+ --kind spans|runs|conversations|verdicts (traces and datasets; default runs)
37
70
  --since 24h|7d|30d|3m|today or --from DATE --to DATE (default 7d)
38
71
  --agent NAME repeatable
39
72
  --where "status=error" = != ~ > >= < <= on any Traces property:
@@ -50,25 +83,33 @@ Training data:
50
83
  trodo datasets export "Good refunds" --rows passed --format openai --target output -o train.jsonl
51
84
  firectl dataset create good-refunds train.jsonl
52
85
 
53
- Auth: TRODO_API_KEY / TRODO_API_URL, or \`trodo login\` (~/.trodo/config.json).
86
+ Auth: TRODO_API_KEY / TRODO_API_URL, or \`trodo login\` (~/.trodo/config.json, one key per team).
54
87
  A key is a Trodo API key (Settings → MCP); it acts as the person who made it, on its team, with its scopes.
55
- Output: JSON by default; --table for a table. Exit codes: 0 ok, 1 API error, 2 usage.
88
+ The CLI reads everything and only adds: nothing it does deletes, archives or replaces data.
89
+ Output: JSON by default; --table for a table, --csv for CSV; lists take --all [--max-items N]. Exit codes: 0 ok, 1 API error, 2 usage.
56
90
  `;
57
91
 
58
92
  const COMMANDS = {
59
- login: ['auth', 'login'], whoami: ['auth', 'whoami'], logout: ['auth', 'logout'],
93
+ login: ['auth', 'login'], whoami: ['auth', 'whoami'], logout: ['auth', 'logout'], api: ['api', 'call'], setup: ['setup', 'setup'], teams: ['auth', 'teams'], switch: ['auth', 'switch'],
60
94
  };
95
+ const ALIASES = { threads: 'conversations', thread: 'conversations', run: 'runs', span: 'spans', dataset: 'datasets', prompt: 'prompts', issue: 'issues', agent: 'agents', eval: 'evals', trace: 'traces' };
61
96
  const GROUPS = {
62
- evals: ['list'],
63
- traces: ['count', 'export'],
64
- runs: ['list', 'get'],
65
- datasets: ['list', 'get', 'create', 'add', 'append', 'update', 'export', 'rows', 'versions'],
66
- agents: ['list', 'run', 'executions', 'execution'],
97
+ evals: ['list', 'get', 'steps', 'verdicts', 'verdict', 'mark', 'queue', 'review', 'grade'],
98
+ traces: ['count', 'stats', 'export', 'agents', 'fields', 'values'],
99
+ runs: ['list', 'search', 'get', 'tree', 'feedback'],
100
+ spans: ['list', 'search', 'get'],
101
+ conversations: ['list', 'get'],
102
+ datasets: ['list', 'get', 'create', 'add', 'append', 'update', 'export', 'rows', 'download', 'versions'],
103
+ prompts: ['list', 'get', 'versions', 'pull', 'push', 'label'],
104
+ issues: ['list', 'get', 'failures', 'close', 'reopen'],
105
+ agents: ['list', 'get', 'run', 'executions', 'execution'],
106
+ users: ['get'],
67
107
  };
68
108
 
69
109
  async function main(argv) {
70
110
  const { flags, positional } = parse(argv);
71
- const [cmd, sub, ...rest] = positional;
111
+ const [cmd0, sub, ...rest] = positional;
112
+ const cmd = ALIASES[cmd0] || cmd0;
72
113
  // `trodo --version` alone is the CLI's version; `datasets export X --version 3` is a dataset's.
73
114
  if (flags.version === true && !cmd) { process.stdout.write(`${pkg.version}\n`); return 0; }
74
115
  if (!cmd || flags.help || cmd === 'help') { process.stdout.write(HELP); return 0; }
package/lib/output.js CHANGED
@@ -7,6 +7,7 @@
7
7
  const fs = require('fs');
8
8
 
9
9
  function print(data, flags = {}, columns = null) {
10
+ if (flags.csv && Array.isArray(data)) return process.stdout.write(csv(data, columns));
10
11
  if (flags.table && columns && Array.isArray(data)) return process.stdout.write(table(data, columns));
11
12
  const pretty = process.stdout.isTTY && !flags.json;
12
13
  return process.stdout.write(`${JSON.stringify(data, null, pretty ? 2 : 0)}\n`);
@@ -20,6 +21,16 @@ function table(rows, columns) {
20
21
  return `${[line(columns.map(([, label]) => label)), ...rows.map((r) => line(columns.map(([key]) => cell(r[key]))))].join('\n')}\n`;
21
22
  }
22
23
 
24
+ /** RFC 4180 CSV: the table's columns when the command has them, every key otherwise. */
25
+ function csv(rows, columns) {
26
+ const keys = columns ? columns.map(([k]) => k) : [...new Set(rows.flatMap((r) => (r && typeof r === 'object' ? Object.keys(r) : [])))];
27
+ const cell = (v) => {
28
+ const s = v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
29
+ return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
30
+ };
31
+ return `${[keys.join(','), ...rows.map((r) => keys.map((k) => cell(r[k])).join(','))].join('\n')}\n`;
32
+ }
33
+
23
34
  /** Write lines to --out or stdout; returns { write(obj), close() }. */
24
35
  function sink(out) {
25
36
  const stream = out ? fs.createWriteStream(out) : process.stdout;
@@ -47,4 +58,4 @@ async function readRows(file) {
47
58
  });
48
59
  }
49
60
 
50
- module.exports = { print, table, sink, readRows };
61
+ module.exports = { print, table, csv, sink, readRows };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "trodo-cli",
3
- "version": "0.2.0",
4
- "description": "Trodo from the terminal: pull traces by their eval results, build datasets, run labeling agents, export fine-tuning data",
3
+ "version": "0.3.0",
4
+ "description": "Trodo from the terminal: traces, evals, datasets, prompts, issues and agents for people, scripts and coding agents",
5
5
  "bin": {
6
6
  "trodo": "bin/trodo.js"
7
7
  },
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/",
11
11
  "lib/",
12
+ "skills/",
12
13
  "README.md"
13
14
  ],
14
15
  "scripts": {