@letterstory/cli 0.138.0 → 0.139.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/lib/cli.mjs CHANGED
@@ -60,10 +60,11 @@ import {
60
60
  cmdCampaigns,
61
61
  cmdTargets,
62
62
  cmdVisits,
63
+ cmdSim,
63
64
  } from "./commands.mjs";
64
65
 
65
66
  // Keep in sync with cli/package.json.
66
- export const VERSION = "0.138.0";
67
+ export const VERSION = "0.139.0";
67
68
 
68
69
  // Flags that never take a value. Listing them explicitly means `deploy get --json <id>`
69
70
  // can't accidentally swallow the id as --json's value. Every `type: "bool"` flag (and its
@@ -71,6 +72,9 @@ export const VERSION = "0.138.0";
71
72
  export const BOOLEAN_FLAGS = new Set([
72
73
  "remap",
73
74
  "json",
75
+ // sim seed: make the new seed active; collections it doesn't cover start empty.
76
+ "activate",
77
+ "empty",
74
78
  // admin models runs: only the starred ones.
75
79
  "starred",
76
80
  // shredder file / arena file: save the version as a candidate beside live.
@@ -1440,6 +1444,17 @@ Visits (Visitors: AI agents reading your site, and AI answers sending people to
1440
1444
  visits pages [--since 28d] [--path '/docs/*'] [--agent <id>] [--limit <n>] [--site <id>]
1441
1445
  People and AI agents per page
1442
1446
 
1447
+ Sim (Simulations: a free, simulated copy of your API for agents, and what they did with it):
1448
+ sim [list] Your simulations and their URLs
1449
+ sim create --name <n> (--spec-url <url>|--spec-file <path>) [--slug <s>] [--upstream-url <url> --upstream-credential <c>]
1450
+ sim show <id> Resources, their serving rules, and who set them
1451
+ sim rule <id> <resource> synthesize|forward|sink|refuse|inferred [--note <why>]
1452
+ sim pause <id> | sim resume <id>
1453
+ sim key <id> [--agent <a>] [--model <m>] [--goal <g>] Mint a key for your own agents or CI
1454
+ sim sessions <id> [--limit N] Agent sessions, newest first
1455
+ sim session <session-id> One session, call by call
1456
+ sim report <id> [--since 30d] Funnel, missing operations, argument errors, loops, by agent
1457
+
1443
1458
  Evals (how the pipeline is doing on your posts, and the regression cases captured from them):
1444
1459
  evals scoreboard [--collection <id>] [--days N] Every measure by stage: posts, failing, rate
1445
1460
  evals cases [--status open|guarding|proposed|retired|rejected] [--stage research|brief|links|mentions|accuracy|pipeline|shred]
@@ -1537,6 +1552,7 @@ const CLIENT_COMMANDS = {
1537
1552
  site: cmdSite,
1538
1553
  evals: cmdEvals,
1539
1554
  visits: cmdVisits,
1555
+ sim: cmdSim,
1540
1556
  // Platform-scoped (staff key) — the only commands that act BEFORE an org exists.
1541
1557
  admin: cmdAdmin,
1542
1558
  };
@@ -0,0 +1,285 @@
1
+ // `sim` — Simulations from a terminal: a free, simulated copy of your API that agents can
2
+ // use, and what they did with it. Thin wrappers over the simulation tools (create_simulation,
3
+ // get_simulation, update_simulation, mint_simulation_key, list_simulation_sessions,
4
+ // get_simulation_session, get_simulation_report), the same lib functions the app calls.
5
+ //
6
+ // letterstory sim create --name "Attio API" --spec-url https://api.attio.com/openapi/api
7
+ // letterstory sim show <id>
8
+ // letterstory sim rule <id> /v2/webhooks sink --note "never call real endpoints"
9
+ // letterstory sim create --name "Letterstory" --tools-file tools.json --tool-route "/api/integrations/tools/{name}"
10
+ // letterstory sim key <id> --agent our-ci --goal "nightly integration test"
11
+ // letterstory sim seed <id> --prompt "a 3-person content team with two blogs" --activate
12
+ // letterstory sim seed <id> --use <seed-id|none>
13
+ // letterstory sim report <id> --since 7d
14
+ // letterstory sim sessions <id>
15
+ // letterstory sim session <session-id>
16
+
17
+ import { readFileSync } from "node:fs";
18
+ import { CliError } from "../client.mjs";
19
+ import { flagStr, printResult, argsFromFlags, refuseUnknownFlags } from "./shared.mjs";
20
+ import { sinceDays } from "./visits.mjs";
21
+
22
+ const RULES = ["synthesize", "forward", "sink", "refuse", "inferred"];
23
+ const simulationId = { positional: 0, label: "simulation-id", required: true };
24
+
25
+ export const ARGS = {
26
+ create_simulation: {
27
+ name: { flag: "name", required: true },
28
+ slug: "slug",
29
+ spec_url: "spec-url",
30
+ spec: {
31
+ value: (flags) => {
32
+ const path = flagStr(flags["spec-file"]);
33
+ if (!path) return undefined;
34
+ try {
35
+ return JSON.parse(readFileSync(path, "utf8"));
36
+ } catch {
37
+ throw new CliError(`--spec-file must be a JSON OpenAPI document (could not parse ${path})`);
38
+ }
39
+ },
40
+ },
41
+ offering_id: "offering",
42
+ upstream_base_url: "upstream-url",
43
+ upstream_credential: "upstream-credential",
44
+ mcp_tools: {
45
+ value: (flags) => {
46
+ const path = flagStr(flags["tools-file"]);
47
+ if (!path) return undefined;
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(path, "utf8"));
51
+ } catch {
52
+ throw new CliError(
53
+ `--tools-file must be JSON: a tools/list result or its tools array (could not parse ${path})`
54
+ );
55
+ }
56
+ return Array.isArray(parsed) ? parsed : (parsed?.result?.tools ?? parsed?.tools);
57
+ },
58
+ },
59
+ tool_route: "tool-route",
60
+ auth_header: "auth-header",
61
+ },
62
+ list_simulations: {},
63
+ get_simulation: { simulation_id: simulationId },
64
+ update_simulation: {
65
+ simulation_id: simulationId,
66
+ status: { flag: "status", enum: ["live", "paused"] },
67
+ active_seed_id: {
68
+ value: (flags) => {
69
+ const v = flagStr(flags.use);
70
+ return v === undefined ? undefined : v === "none" ? null : v;
71
+ },
72
+ },
73
+ resource_rules: {
74
+ value: (flags, positionals) => {
75
+ if (positionals.length < 3) return undefined;
76
+ const rule = positionals[2];
77
+ if (!RULES.includes(rule)) throw new CliError(`rule must be one of: ${RULES.join(", ")}`);
78
+ return [
79
+ { resource: positionals[1], rule: rule === "inferred" ? null : rule, note: flagStr(flags.note) },
80
+ ];
81
+ },
82
+ },
83
+ },
84
+ generate_simulation_seed: {
85
+ simulation_id: simulationId,
86
+ prompt: { flag: "prompt", required: true },
87
+ resources: { flag: "resources", type: "list" },
88
+ items_per_collection: { flag: "items", type: "num" },
89
+ empty_workspace: { flag: "empty", type: "bool" },
90
+ activate: { flag: "activate", type: "bool" },
91
+ },
92
+ mint_simulation_key: { simulation_id: simulationId, agent: "agent", model: "model", goal: "goal" },
93
+ list_simulation_sessions: { simulation_id: simulationId, limit: { flag: "limit", type: "num" } },
94
+ get_simulation_session: { session_id: { positional: 0, label: "session-id", required: true } },
95
+ get_simulation_report: { simulation_id: simulationId, days: { value: (flags) => sinceDays(flags.since) } },
96
+ };
97
+
98
+ function call(ctx, tool) {
99
+ return ctx.client.callTool(tool, argsFromFlags(ARGS[tool], ctx.flags, ctx.positionals));
100
+ }
101
+
102
+ export async function cmdSim(ctx) {
103
+ const sub = ctx.positionals[0];
104
+ const rest = { ...ctx, positionals: ctx.positionals.slice(1) };
105
+ switch (sub) {
106
+ case undefined:
107
+ case "list":
108
+ case "ls":
109
+ return simList(rest);
110
+ case "create":
111
+ refuseUnknownFlags(rest, ARGS.create_simulation, ["spec-file", "tools-file"]);
112
+ return simShow(rest, await call(rest, "create_simulation"));
113
+ case "show":
114
+ return simShow(rest, await call(rest, "get_simulation"));
115
+ case "rule":
116
+ case "pause":
117
+ case "resume": {
118
+ refuseUnknownFlags(rest, ARGS.update_simulation, ["note"]);
119
+ if (sub !== "rule") rest.flags = { ...rest.flags, status: sub === "pause" ? "paused" : "live" };
120
+ return simShow(rest, await call(rest, "update_simulation"));
121
+ }
122
+ case "seed":
123
+ return simSeed(rest);
124
+ case "key":
125
+ return simKey(rest);
126
+ case "sessions":
127
+ return simSessions(rest);
128
+ case "session":
129
+ return simSession(rest);
130
+ case "report":
131
+ return simReport(rest);
132
+ default:
133
+ throw new CliError("Usage: sim [list|create|show|rule|pause|resume|seed|key|sessions|session|report]");
134
+ }
135
+ }
136
+
137
+ async function simList(ctx) {
138
+ const result = await call(ctx, "list_simulations");
139
+ printResult(ctx.io, ctx.flags, result, (r) => {
140
+ if (!r.simulations?.length)
141
+ return "No simulations yet. Create one: sim create --name <name> --spec-url <openapi url>";
142
+ return r.simulations
143
+ .map(
144
+ (s) =>
145
+ `${s.id} ${s.name.padEnd(24)} ${s.status.padEnd(7)} ${String(s.sessions_7d).padStart(4)} sessions/7d ${s.urls.base}`
146
+ )
147
+ .join("\n");
148
+ });
149
+ return 0;
150
+ }
151
+
152
+ function simShow(ctx, result) {
153
+ printResult(ctx.io, ctx.flags, result, (r) => {
154
+ const s = r.simulation;
155
+ const lines = [
156
+ `${s.name} (${s.title} v${s.version}) — ${s.status}, ${s.operations} operations`,
157
+ ` REST ${s.urls.base}`,
158
+ ` MCP ${s.urls.mcp}`,
159
+ ` Docs ${s.urls.llms}`,
160
+ "",
161
+ "Resources (rule · side effect · who set it):",
162
+ ];
163
+ for (const res of r.resources ?? []) {
164
+ lines.push(` ${res.rule.padEnd(10)} ${res.side_effect.padEnd(9)} ${res.set_by.padEnd(8)} ${res.resource}`);
165
+ }
166
+ return lines.join("\n");
167
+ });
168
+ return 0;
169
+ }
170
+
171
+ /** `sim seed <id> --prompt …` writes a seed; `sim seed <id> --use <seed-id|none>` switches the active one. */
172
+ async function simSeed(ctx) {
173
+ if (ctx.flags.use !== undefined) {
174
+ refuseUnknownFlags(ctx, ARGS.update_simulation, ["use"]);
175
+ return simShow(ctx, await call(ctx, "update_simulation"));
176
+ }
177
+ const result = await call(ctx, "generate_simulation_seed");
178
+ printResult(ctx.io, ctx.flags, result, (r) => {
179
+ const s = r.seed;
180
+ const rep = s.report;
181
+ return [
182
+ `Seed v${s.version} (${s.id})${s.active ? " — active" : ""}, by ${s.model ?? "upload"}`,
183
+ ...Object.entries(s.items).map(([k, n]) => ` ${k}: ${n} items`),
184
+ `Kept ${rep.kept}, completed ${rep.filled}, dropped ${rep.dropped}, dangling ids ${rep.dangling.length}`,
185
+ s.active ? "" : `Make it active: sim seed <simulation-id> --use ${s.id}`,
186
+ ]
187
+ .filter(Boolean)
188
+ .join("\n");
189
+ });
190
+ return 0;
191
+ }
192
+
193
+ async function simKey(ctx) {
194
+ const result = await call(ctx, "mint_simulation_key");
195
+ printResult(ctx.io, ctx.flags, result, (r) =>
196
+ [
197
+ `Key (shown once): ${r.key}`,
198
+ `Expires: ${r.expires_at}`,
199
+ `Base URL: ${r.urls.base}`,
200
+ `Session: ${r.session_id}`,
201
+ ].join("\n")
202
+ );
203
+ return 0;
204
+ }
205
+
206
+ async function simSessions(ctx) {
207
+ const result = await call(ctx, "list_simulation_sessions");
208
+ printResult(ctx.io, ctx.flags, result, (r) => {
209
+ if (!r.sessions?.length) return "No sessions yet.";
210
+ return r.sessions
211
+ .map(
212
+ (s) =>
213
+ `${s.id} ${String(s.calls).padStart(4)} calls ${(s.agent ?? s.client ?? s.requester_agent ?? "unknown").padEnd(22)} ${s.goal ?? ""}`
214
+ )
215
+ .join("\n");
216
+ });
217
+ return 0;
218
+ }
219
+
220
+ async function simSession(ctx) {
221
+ const result = await call(ctx, "get_simulation_session");
222
+ printResult(ctx.io, ctx.flags, result, (r) => {
223
+ const lines = [
224
+ `Session ${r.session.id} — ${r.session.agent ?? r.session.client ?? "unknown agent"}, ${r.calls.length} events`,
225
+ ];
226
+ for (const c of r.calls) {
227
+ if (c.kind === "feedback") {
228
+ lines.push(` ${String(c.seq).padStart(3)} feedback: ${c.note}`);
229
+ continue;
230
+ }
231
+ const errs =
232
+ Array.isArray(c.field_errors) && c.field_errors.length
233
+ ? ` ${c.field_errors.map((e) => `${e.path || "body"}:${e.code}`).join(", ")}`
234
+ : "";
235
+ lines.push(
236
+ ` ${String(c.seq).padStart(3)} ${String(c.status).padEnd(4)}${(c.served ?? "").padEnd(12)} ${c.method} ${c.path}${errs}`
237
+ );
238
+ }
239
+ return lines.join("\n");
240
+ });
241
+ return 0;
242
+ }
243
+
244
+ async function simReport(ctx) {
245
+ const result = await call(ctx, "get_simulation_report");
246
+ printResult(ctx.io, ctx.flags, result, ({ report: r }) => {
247
+ const f = r.funnel;
248
+ const lines = [
249
+ `${r.sessions} sessions, ${r.calls} calls in ${r.window_days} days`,
250
+ `Funnel: ${f.minted} keys → ${f.called} called → ${f.succeeded} succeeded → ${f.wrote} wrote`,
251
+ `First success: median ${r.first_success.median_calls ?? "–"} calls, ${r.first_success.median_seconds ?? "–"}s`,
252
+ ];
253
+ if (r.hallucinated.length) {
254
+ lines.push("", "Expected but missing:");
255
+ for (const h of r.hallucinated.slice(0, 10))
256
+ lines.push(
257
+ ` ${String(h.sessions).padStart(3)} sessions ${h.method} ${h.path}${h.nearest_operation ? ` (nearest: ${h.nearest_operation})` : ""}`
258
+ );
259
+ }
260
+ if (r.field_errors.length) {
261
+ lines.push("", "Argument errors:");
262
+ for (const e of r.field_errors.slice(0, 10))
263
+ lines.push(` ${String(e.count).padStart(3)}× ${e.operation_id} ${e.field} (${e.code})`);
264
+ }
265
+ if (r.error_loops.length) {
266
+ lines.push("", "Error loops:");
267
+ for (const l of r.error_loops)
268
+ lines.push(` ${l.operation_id}: ${l.sessions} sessions, up to ${l.longest} in a row`);
269
+ }
270
+ if (r.by_agent.length) {
271
+ lines.push("", "By agent:");
272
+ for (const a of r.by_agent)
273
+ lines.push(
274
+ ` ${a.agent.padEnd(22)} ${a.sessions} sessions, ${a.calls} calls, ${Math.round(a.success_rate * 100)}% ok`
275
+ );
276
+ }
277
+ if (r.feedback.length) {
278
+ lines.push("", "Feedback:");
279
+ for (const fb of r.feedback.slice(-5)) lines.push(` “${fb.note.slice(0, 200)}”`);
280
+ }
281
+ lines.push("", `Unused: ${r.unused.count} of ${r.unused.total} operations`);
282
+ return lines.join("\n");
283
+ });
284
+ return 0;
285
+ }
package/lib/commands.mjs CHANGED
@@ -47,3 +47,4 @@ export * from "./commands/repo.mjs";
47
47
  export * from "./commands/site.mjs";
48
48
  export * from "./commands/evals.mjs";
49
49
  export * from "./commands/visits.mjs";
50
+ export * from "./commands/sim.mjs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@letterstory/cli",
3
- "version": "0.138.0",
3
+ "version": "0.139.0",
4
4
  "description": "Spin up and manage Letterstory phantom blogs from your terminal.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",