foldrun 0.8.0 → 0.9.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/src/commands.mjs CHANGED
@@ -78,7 +78,7 @@ function shippedTemplates() {
78
78
  }
79
79
 
80
80
  /** The files of one workspace: a template if asked for, else the starter. */
81
- async function workspaceFiles(name, from) {
81
+ async function workspaceFiles(name, from, starter = true) {
82
82
  const { starterFiles } = await core();
83
83
  // A template is a source, a workspace is a destination. Keeping the two
84
84
  // words apart is the whole reason `templates/` is not called `examples/`:
@@ -91,7 +91,15 @@ async function workspaceFiles(name, from) {
91
91
  if (from && !source) {
92
92
  throw new Error(`no template at ${from} — pass a directory, or one that ships with foldrun: ${shippedTemplates().join(", ") || "none found"}`);
93
93
  }
94
- const files = source ? templateFilesFrom(source) : starterFiles(name);
94
+ // Blank unless asked: the example researcher and writer met people as
95
+ // clutter in a workspace made for real work (owner, 2026-10-05). --starter
96
+ // brings them back; --from copies a template.
97
+ const { blankWorkspaceFiles } = /** @type {any} */ (await core());
98
+ const files = source
99
+ ? templateFilesFrom(source)
100
+ : starter || !blankWorkspaceFiles
101
+ ? starterFiles(name)
102
+ : blankWorkspaceFiles(name);
95
103
 
96
104
  // Whatever the source, the new workspace must ignore the key that decrypts
97
105
  // its secrets. A template does not carry one — it is a source, not a
@@ -206,7 +214,11 @@ function report(where, written, outside, wsDir, files) {
206
214
  console.log(`
207
215
  ${c.bold("Next")}
208
216
  foldrun check ${rel}${" ".repeat(Math.max(1, 16 - rel.length))}${c.dim("validate it — costs nothing")}
209
- foldrun run ${flow ?? "publish"} --workspace ${path.relative(process.cwd(), wsDir) || "."} ${c.dim("run the flow")}
217
+ ${
218
+ flow
219
+ ? `foldrun run ${flow} --workspace ${path.relative(process.cwd(), wsDir) || "."} ${c.dim("run the flow")}`
220
+ : `foldrun agent import <workspace>/<agent> ${c.dim("bring in an agent you already have — or `foldrun agent new <name>`")}`
221
+ }
210
222
  `);
211
223
  }
212
224
 
@@ -231,8 +243,11 @@ async function newWorkspace(name, flags, layout) {
231
243
  }
232
244
  const dir = path.join(layout.workspacesDir, name);
233
245
  if (fs.existsSync(dir)) throw new Error(`${dir} already exists`);
234
- const files = await workspaceFiles(name, flags.from);
246
+ const files = await workspaceFiles(name, flags.from, flags.starter === true);
235
247
  writeFiles(dir, files);
248
+ // A workspace is a folder holding agents/ or flows/; a blank one has
249
+ // neither file yet, so the folders are made for it to be found as one.
250
+ for (const sub of ["agents", "flows"]) fs.mkdirSync(path.join(dir, sub), { recursive: true });
236
251
  syncWorkspaceBundles(dir);
237
252
  report(dir, files.map((f) => f.path), [], dir, files);
238
253
  return 0;
@@ -252,7 +267,7 @@ function workspaceDirFor(flags, layout) {
252
267
  if (typeof flags.to === "string") {
253
268
  if (!layout.workspacesDir || !layout.workspaces.includes(flags.to)) {
254
269
  throw new Error(
255
- `no workspace called "${flags.to}" here — this ${layout.kind === "flat" ? "is a single workspace" : `account has ${layout.workspaces.join(", ") || "none"}`}`,
270
+ `no workspace called "${flags.to}" here — this ${layout.kind === "flat" ? "is a single workspace" : `account has ${layout.workspaces.join(", ") || "none"}`} (--platform makes it in the deployed one)`,
256
271
  );
257
272
  }
258
273
  return path.join(layout.workspacesDir, flags.to);
@@ -328,7 +343,7 @@ async function scaffoldCmd(kind, positional, flags, layout) {
328
343
 
329
344
  // The other verbs are dispatched before this — `agent run` and `tool test`
330
345
  // go to the platform — so anything left here should have been `new`.
331
- const alsoVerbs = { agents: ["run", "link", "unlink"], tools: ["test"], flows: ["add", "rm-step", "dup-step", "show", "run", "rotate-hook", "set", "trigger", "move-step", "copy-step", "paste", "draft"] }[kind] ?? [];
346
+ const alsoVerbs = { agents: ["run", "link", "unlink", "import", "ls"], tools: ["test"], flows: ["ls", "add", "rm-step", "dup-step", "show", "run", "hook", "rotate-hook", "set", "trigger", "move-step", "copy-step", "paste", "draft"] }[kind] ?? [];
332
347
  const verb = positional[0];
333
348
  if (verb !== "new") {
334
349
  const verbs = ["new", ...alsoVerbs].join(", ");
@@ -341,6 +356,10 @@ async function scaffoldCmd(kind, positional, flags, layout) {
341
356
  if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
342
357
  throw new Error(`"${name}" is not a ${one} name — kebab-case only, e.g. ${KINDS[kind].placeholder}`);
343
358
  }
359
+ // --platform: the dashboard's New button, on the deployed workspace —
360
+ // nothing is written here. `--to` alone still names a workspace of this
361
+ // folder, as it always has.
362
+ if (flags.platform === true) return platformScaffold(kind, name, flags, layout);
344
363
 
345
364
  const dir = workspaceDirFor(flags, layout);
346
365
  let files;
@@ -375,6 +394,74 @@ async function scaffoldCmd(kind, positional, flags, layout) {
375
394
  return 0;
376
395
  }
377
396
 
397
+ /**
398
+ * `foldrun agent new <name> --platform`, `flow new … --platform`, `tool new
399
+ * … --platform` — the dashboard's New button on a deployed workspace: the
400
+ * platform writes its own template (POST …/agents, …/flows, …/assets), so
401
+ * what lands is what the canvas would have made, as a revision.
402
+ */
403
+ async function platformScaffold(kind, name, flags, layout) {
404
+ const one = { agents: "agent", flows: "flow", tools: "tool" }[kind];
405
+ const url = platformFor(flags, `${one} new --platform`);
406
+ const ws = platformWorkspace(flags, layout, `${one} new ${name} --platform`);
407
+ let route = `/api/workspaces/${enc(ws)}/${kind}`;
408
+ let body;
409
+ if (kind === "agents") {
410
+ body = { name, ...(typeof flags.description === "string" ? { description: flags.description } : {}), ...(typeof flags.model === "string" ? { model: flags.model } : {}) };
411
+ } else if (kind === "flows") {
412
+ if (flags.pattern !== undefined && !NEW_FLOW_SHAPES.includes(flags.pattern)) {
413
+ throw new Error(`--pattern takes ${NEW_FLOW_SHAPES.join(", ")} — not "${flags.pattern}"`);
414
+ }
415
+ body = { name, ...(typeof flags.pattern === "string" ? { pattern: flags.pattern } : {}) };
416
+ } else {
417
+ // A script tool, as `tool new` makes here — the platform's own default
418
+ // with no template is an http one.
419
+ route = `/api/workspaces/${enc(ws)}/assets`;
420
+ const template = typeof flags.template === "string" ? flags.template : typeof flags.transport === "string" ? flags.transport : "script";
421
+ body = { kind: "tools", name, template, ...(typeof flags.language === "string" ? { language: flags.language } : {}) };
422
+ }
423
+ const r = await remoteCall(url, flags, route, { method: "POST", body: JSON.stringify(body) });
424
+ console.log(`\n ${c.green("created")} ${one} ${c.bold(name)} in ${ws} ${c.dim(`on ${url}`)}`);
425
+ console.log(` ${c.dim(r.path ?? "")}${r.pattern ? c.dim(` · ${r.pattern}`) : ""}`);
426
+ console.log(`\n ${c.dim(`foldrun source cat ${r.path ?? "<path>"} --to ${ws} · foldrun check --to ${ws} · foldrun pull ${ws} brings it here`)}\n`);
427
+ return 0;
428
+ }
429
+
430
+ /** The shapes the platform's New flow offers (core's store FLOW_PATTERNS). */
431
+ const NEW_FLOW_SHAPES = ["pipeline", "review-loop", "fan-out", "debate", "router"];
432
+
433
+ /**
434
+ * `foldrun agent ls` / `flow ls` — what is deployed in a workspace (GET
435
+ * …/agents, …/flows): the list the dashboard draws, from a terminal.
436
+ */
437
+ async function remoteListCmd(kind, flags, layout) {
438
+ const one = kind === "agents" ? "agent" : "flow";
439
+ const url = platformFor(flags, `${one} ls`);
440
+ const ws = platformWorkspace(flags, layout, `${one} ls`);
441
+ const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/${kind}`);
442
+ const list = r[kind] ?? [];
443
+ if (flags.json === true) {
444
+ console.log(JSON.stringify(list, null, 2));
445
+ return 0;
446
+ }
447
+ if (!list.length) {
448
+ console.log(`\n ${c.dim(`no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
449
+ return 0;
450
+ }
451
+ const w = Math.max(...list.map((x) => x.name.length));
452
+ console.log();
453
+ for (const x of list) {
454
+ if (kind === "agents") {
455
+ console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${c.dim(firstLine(x.description ?? "", 80))}`);
456
+ } else {
457
+ const when = x.trigger === "schedule" && x.schedule ? `schedule ${x.schedule}${x.timezone ? ` ${x.timezone}` : ""}` : (x.trigger ?? "manual");
458
+ console.log(` ${c.bold(pad(x.name, w))} ${c.dim(when)}`);
459
+ }
460
+ }
461
+ console.log(`\n ${c.dim(`${list.length} ${list.length === 1 ? one : kind} · ${ws} · ${url}`)}\n`);
462
+ return 0;
463
+ }
464
+
378
465
  // ---------------------------------------------------------------- check
379
466
 
380
467
  // The Agent Skills spec constrains the `name` field and requires a non-empty
@@ -777,7 +864,10 @@ async function checkProblems(workspace, flags = {}) {
777
864
  }
778
865
  for (const s of platform.skills) skillNames.add(s);
779
866
 
780
- if (agentNames.size === 0) note("error", "agents/", "no agents — a workspace needs at least one");
867
+ // A blank workspace is legal — `foldrun new` and the dashboard make one —
868
+ // so this is a nudge, as the deploy's warning is; a flow naming a missing
869
+ // agent is still an error below.
870
+ if (agentNames.size === 0) note("warn", "agents/", "no agents yet — `foldrun agent new <name>`, or `foldrun agent import <workspace>/<agent>`");
781
871
 
782
872
  // What format does this workspace target?
783
873
  const agentsMd = path.join(workspace, "AGENTS.md");
@@ -793,7 +883,13 @@ async function checkProblems(workspace, flags = {}) {
793
883
  for (const w of readLimits(readAgentsMd(workspace)?.data?.limits).problems) note("error", "AGENTS.md", w);
794
884
  }
795
885
 
886
+ const { unopenedFrontmatter } = /** @type {any} */ (await core());
796
887
  for (const a of agents) {
888
+ // A frontmatter block with no opening `---` is read as prose: no tools,
889
+ // the default model (seo-digest's editor, 20 Sep to 5 Oct 2026).
890
+ const agentFile = path.join(workspace, "agents", a.name, "agent.md");
891
+ const unopened = unopenedFrontmatter && fs.existsSync(agentFile) ? unopenedFrontmatter(fs.readFileSync(agentFile, "utf8")) : null;
892
+ if (unopened) note("error", `agents/${a.name}/agent.md`, unopened, 1);
797
893
  if (!a.description) note("warn", `agents/${a.name}`, "no description — other agents and people read it");
798
894
 
799
895
  // A timezone nobody can read. The deploy already refuses it; check has to
@@ -3630,7 +3726,25 @@ async function sourceCmd(positional, flags) {
3630
3726
  console.log(`\n ${c.green("✓")} removed ${a}\n`);
3631
3727
  return 0;
3632
3728
  }
3633
- throw new Error(`source: unknown verb "${verb}" — ls, cat, put, mv, rm`);
3729
+ if (verb === "new") {
3730
+ // The dashboard's New button for any document (POST …/assets): the
3731
+ // platform writes the template — a tool folder, a skill, knowledge,
3732
+ // memory, a script — at workspace scope, or inside one agent (--agent).
3733
+ const kinds = ["tools", "knowledge", "memory", "skills", "scripts", "agents", "flows", "evals"];
3734
+ if (!kinds.includes(a) || !b) throw new Error(`\`foldrun source new <kind> <name> --to <workspace>\` — kind is ${kinds.join(", ")} (--agent <name>, --template, --language)`);
3735
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(b)) throw new Error(`"${b}" is not a name — kebab-case only`);
3736
+ const body = {
3737
+ kind: a,
3738
+ name: b,
3739
+ ...(typeof flags.agent === "string" ? { agent: flags.agent } : {}),
3740
+ ...(typeof flags.template === "string" ? { template: flags.template } : {}),
3741
+ ...(typeof flags.language === "string" ? { language: flags.language } : {}),
3742
+ };
3743
+ const r = await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(ws)}/assets`, { method: "POST", body: JSON.stringify(body) });
3744
+ console.log(`\n ${c.green("✓")} ${ws}/${r.path ?? b} ${c.dim(`a new ${a.replace(/s$/, "")}${flags.agent ? ` for ${flags.agent}` : ""} · revision recorded`)}\n`);
3745
+ return 0;
3746
+ }
3747
+ throw new Error(`source: unknown verb "${verb}" — ls, cat, put, new, mv, rm`);
3634
3748
  }
3635
3749
 
3636
3750
  // ------------------------------------------------- the account, as a whole
@@ -3964,6 +4078,7 @@ async function reportRuntimes(url, workspace, flags, { waitSeconds = 0 } = {}) {
3964
4078
  r.python && r.python !== true ? `python ${r.python}` : null,
3965
4079
  ...(r.packages ?? []),
3966
4080
  ...(r.npm ?? []).map((n) => `npm:${n}`),
4081
+ ...(r.system ?? []).map((n) => `apt:${n}`),
3967
4082
  ].filter(Boolean).join(", ") || "python";
3968
4083
  const who = c.dim(`(${(r.agents ?? []).join(", ")})`);
3969
4084
  const mark =
@@ -4034,6 +4149,7 @@ function printIncidents(report) {
4034
4149
  * open GET /api/status; exits 1 when the platform does not answer.
4035
4150
  */
4036
4151
  async function platformStatusCmd(flags) {
4152
+ if (flags.history === true) return platformHistoryCmd(flags);
4037
4153
  const url = remoteUrl(flags) ?? DEFAULT_PLATFORM;
4038
4154
  const { report, error } = await fetchPlatformStatus(url, flags);
4039
4155
  if (flags.json === true) {
@@ -4059,6 +4175,43 @@ async function platformStatusCmd(flags) {
4059
4175
  return 0;
4060
4176
  }
4061
4177
 
4178
+ /**
4179
+ * `foldrun status --platform --history [--days n]` — what the status page
4180
+ * draws as bars: each component's uptime per day over the window (1–90,
4181
+ * default 90), and the incidents in it. The open GET /api/status/history.
4182
+ */
4183
+ async function platformHistoryCmd(flags) {
4184
+ const url = remoteUrl(flags) ?? DEFAULT_PLATFORM;
4185
+ const days = flags.days === undefined ? 90 : Number(flags.days);
4186
+ if (!Number.isInteger(days) || days < 1 || days > 90) throw new Error(`--days is a whole number from 1 to 90 — not "${flags.days}"`);
4187
+ const res = await remoteFetch(url, `/api/status/history?days=${days}`, {}, { seconds: timeoutSeconds(flags) });
4188
+ /** @type {any} */
4189
+ const body = await res.json().catch(() => ({}));
4190
+ if (!res.ok) throw new HttpError(body.error ?? `/api/status/history → HTTP ${res.status}`, res.status, body);
4191
+ if (flags.json === true) {
4192
+ console.log(JSON.stringify(body, null, 2));
4193
+ return 0;
4194
+ }
4195
+ const share = (u) => (u === null || u === undefined ? " — " : `${(u * 100).toFixed(u === 1 ? 0 : 2)}%`.padStart(6));
4196
+ const mark = (d) => (d.uptime === null ? c.dim("·") : d.outage ? c.red("▮") : d.degraded || d.maintenance ? c.amber("▮") : c.green("▮"));
4197
+ const comps = body.components ?? [];
4198
+ const wide = Math.max(0, ...comps.map((x) => x.name.length));
4199
+ console.log(`\n ${c.bold(`last ${body.days ?? days} day${(body.days ?? days) === 1 ? "" : "s"}`)} ${c.dim(`${url} · a sample every ${body.sampleMinutes ?? 5} minutes · uptime is the share not in outage`)}\n`);
4200
+ for (const comp of comps) console.log(` ${comp.name.padEnd(wide)} ${share(comp.uptime)} ${(comp.days ?? []).map(mark).join("")}`);
4201
+ const incidents = body.incidents ?? [];
4202
+ if (incidents.length) {
4203
+ console.log(`\n ${c.dim("incidents and maintenance")}`);
4204
+ for (const i of incidents) {
4205
+ const span = `${String(i.startsAt).slice(0, 16).replace("T", " ")}Z${i.resolvedAt ? ` → ${String(i.resolvedAt).slice(0, 16).replace("T", " ")}Z` : " · open"}`;
4206
+ console.log(` ${i.kind === "incident" ? (i.impact === "major" ? c.red("!") : c.amber("!")) : c.dim("~")} ${c.bold(i.title)} ${c.dim(`${i.kind === "incident" ? `${i.impact} incident` : "maintenance"} · ${span}`)}`);
4207
+ }
4208
+ } else {
4209
+ console.log(`\n ${c.dim("no incidents in the window")}`);
4210
+ }
4211
+ console.log(`\n ${c.dim("▮ a day: green up, amber degraded or maintenance, red an outage, · no samples · --days <n>, --json")}\n`);
4212
+ return 0;
4213
+ }
4214
+
4062
4215
  async function statusCmd(layout, flags, only) {
4063
4216
  if (flags.platform === true) return platformStatusCmd(flags);
4064
4217
  const { isSourcePath, accountTreeFrom } = await core();
@@ -4191,7 +4344,21 @@ async function workspacesCmd(positional, flags, layout) {
4191
4344
  const [verb, name] = positional;
4192
4345
  const url = flags.local === true ? undefined : remoteUrl(flags);
4193
4346
 
4194
- if (verb === "new" || verb === "add") return newWorkspace(name, flags, layout);
4347
+ if (verb === "new" || verb === "add") {
4348
+ if (flags.platform !== true) return newWorkspace(name, flags, layout);
4349
+ // The dashboard's Create workspace: blank (an AGENTS.md), or with
4350
+ // --starter the example researcher, writer and publish flow.
4351
+ if (!url) throw new Error("--platform needs a platform — pass --url, or `foldrun login` first");
4352
+ if (!name) throw new Error("which workspace? `foldrun workspaces new <name> --platform`");
4353
+ assertName(name);
4354
+ const r = await remoteCall(url, flags, "/api/workspaces", {
4355
+ method: "POST",
4356
+ body: JSON.stringify(flags.starter === true ? { name, starter: true } : { name, template: true }),
4357
+ });
4358
+ console.log(`\n ${c.green("✓")} ${c.bold(r.name ?? name)} ${c.dim(`on ${url} — ${r.agents ?? 0} agent${r.agents === 1 ? "" : "s"}, ${r.flows ?? 0} flow${r.flows === 1 ? "" : "s"}`)}`);
4359
+ console.log(` ${c.dim(`foldrun agent new <name> --platform --to ${r.name ?? name} · foldrun pull ${r.name ?? name}`)}\n`);
4360
+ return 0;
4361
+ }
4195
4362
 
4196
4363
  if (verb === "rm" || verb === "remove" || verb === "delete") {
4197
4364
  if (!name) throw new Error("which workspace? `foldrun workspaces rm <name>`");
@@ -4220,7 +4387,7 @@ async function workspacesCmd(positional, flags, layout) {
4220
4387
  return 0;
4221
4388
  }
4222
4389
  if (verb && verb !== "ls" && verb !== "list") {
4223
- throw new Error(`workspaces: unknown verb "${verb}" — ls, new <name>, rm <name>`);
4390
+ throw new Error(`workspaces: unknown verb "${verb}" — ls, show <name>, new <name>, rm <name>, demo`);
4224
4391
  }
4225
4392
 
4226
4393
  const here = layout.workspaces;
@@ -4234,6 +4401,23 @@ async function workspacesCmd(positional, flags, layout) {
4234
4401
  }
4235
4402
  }
4236
4403
  const all = [...new Set([...here, ...there])].sort();
4404
+ // --json: for a script — `foldrun workspaces --json | jq -r '.workspaces[].name'`.
4405
+ // The table's footer once read as a workspace called "is" to a loop that
4406
+ // parsed it. Unreachable platform: still the list, with `error`, exit 1.
4407
+ if (flags.json === true) {
4408
+ console.log(
4409
+ JSON.stringify(
4410
+ {
4411
+ platform: url ?? null,
4412
+ workspaces: all.map((n) => ({ name: n, here: here.includes(n), platform: there.includes(n) })),
4413
+ ...(reach ? { error: reach } : {}),
4414
+ },
4415
+ null,
4416
+ 2,
4417
+ ),
4418
+ );
4419
+ return reach ? 1 : 0;
4420
+ }
4237
4421
  if (!all.length) {
4238
4422
  console.log(`\n ${c.dim("no workspaces — `foldrun new <name>` makes one")}\n`);
4239
4423
  return 0;
@@ -4643,7 +4827,7 @@ async function triggersCmd(flags, layout) {
4643
4827
  const ws = typeof flags.to === "string" ? flags.to : layout?.kind === "empty" ? null : takeWorkspace([], layout);
4644
4828
  if (!ws) throw new Error("which workspace? `foldrun triggers --to <workspace>` — or run it inside one");
4645
4829
  const days = Number(flags.since) > 0 ? Math.floor(Number(flags.since)) : 7;
4646
- const body = await remoteCall(url, flags, `/api/workspaces/${ws}/triggers?since=${days}`);
4830
+ const body = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/triggers?days=${days}`);
4647
4831
  const rows = body.flows ?? [];
4648
4832
  if (!rows.length) {
4649
4833
  console.log(`\n ${c.dim(`no trigger fired in ${ws} in the last ${body.days ?? days} day${(body.days ?? days) === 1 ? "" : "s"} — a flow fires when its frontmatter says trigger: schedule, webhook, watch, storage or email`)}\n`);
@@ -4750,6 +4934,65 @@ async function providersCmd(url, flags) {
4750
4934
  return broken ? 1 : 0;
4751
4935
  }
4752
4936
 
4937
+ /**
4938
+ * `foldrun account model` — the account's own model key: what every step
4939
+ * runs on unless a provider: block names another (/api/account/model).
4940
+ *
4941
+ * foldrun account model what is set, what runs
4942
+ * foldrun account model set <provider> --key <api key> a preset (anthropic,
4943
+ * openrouter, openai, groq, …) or `custom --base-url https://… --format openai`
4944
+ * foldrun account model remove
4945
+ *
4946
+ * `--key -` reads the key from stdin, so it stays out of the shell's history.
4947
+ * The platform refuses a Claude login token for any account not allowed its
4948
+ * own credential: bring an API key.
4949
+ */
4950
+ async function accountModelCmd(url, positional, flags) {
4951
+ const verb = positional[0];
4952
+ const show = (r) => {
4953
+ const runs =
4954
+ r.runsOn === "account"
4955
+ ? `${c.green("✓")} your own key — ${c.bold(r.provider ?? "custom")} ${c.dim(`${r.keyHint ?? ""} · ${r.baseUrl ?? ""} · ${r.format ?? ""}`)}`
4956
+ : r.runsOn === "platform"
4957
+ ? `${c.yellow("·")} the platform's credential ${c.dim("(this account is allowed it)")}`
4958
+ : `${c.red("✗")} no key — runs on this account are refused until one is set`;
4959
+ console.log(`\n ${runs}`);
4960
+ console.log(`\n ${c.dim("foldrun account model set <provider> --key <api key> · remove · a provider: block in AGENTS.md wins over it")}\n`);
4961
+ };
4962
+ if (!verb) {
4963
+ const r = await remoteCall(url, flags, "/api/account/model");
4964
+ if (flags.json === true) console.log(JSON.stringify(r, null, 2));
4965
+ else show(r);
4966
+ return r.runsOn === "none" ? 1 : 0;
4967
+ }
4968
+ if (verb === "remove") {
4969
+ const r = await remoteCall(url, flags, "/api/account/model", { method: "DELETE" });
4970
+ if (flags.json === true) console.log(JSON.stringify(r, null, 2));
4971
+ else show(r);
4972
+ return 0;
4973
+ }
4974
+ if (verb !== "set") throw new Error("`foldrun account model`, `foldrun account model set <provider> --key <api key>`, or `foldrun account model remove`");
4975
+ const provider = positional[1];
4976
+ if (!provider) throw new Error("which provider? `foldrun account model set anthropic --key …` (a preset name, or `custom --base-url https://… --format openai`)");
4977
+ let apiKey = typeof flags.key === "string" ? flags.key : "";
4978
+ if (apiKey === "-") apiKey = fs.readFileSync(0, "utf8").trim();
4979
+ if (!apiKey) throw new Error("--key <api key> (or --key - to read it from stdin)");
4980
+ const body = {
4981
+ provider,
4982
+ apiKey,
4983
+ ...(typeof flags["base-url"] === "string" ? { baseUrl: flags["base-url"] } : {}),
4984
+ ...(typeof flags.format === "string" ? { format: flags.format } : {}),
4985
+ };
4986
+ const r = await remoteCall(url, flags, "/api/account/model", {
4987
+ method: "PUT",
4988
+ body: JSON.stringify(body),
4989
+ headers: { "idempotency-key": crypto.randomUUID() },
4990
+ });
4991
+ if (flags.json === true) console.log(JSON.stringify(r, null, 2));
4992
+ else show(r);
4993
+ return 0;
4994
+ }
4995
+
4753
4996
  // --------------------------------------------------------------- billing
4754
4997
 
4755
4998
  /**
@@ -4847,9 +5090,10 @@ async function accountCmd(positional, flags) {
4847
5090
  return 0;
4848
5091
  }
4849
5092
  if (verb === "providers") return providersCmd(url, flags);
5093
+ if (verb === "model") return accountModelCmd(url, positional.slice(1), flags);
4850
5094
  if (verb === "export") return accountExportCmd(url, flags);
4851
5095
  if (verb !== "set" && verb !== "clear") {
4852
- throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`providers\` checks the model keys, \`export\` downloads everything`);
5096
+ throw new Error(`unknown account verb "${verb}" — \`foldrun account\` shows them, \`set\` and \`clear\` change one, \`model\` is the account's own model key, \`providers\` checks the model keys, \`export\` downloads everything`);
4853
5097
  }
4854
5098
 
4855
5099
  const key = positional[1];
@@ -5160,6 +5404,12 @@ const waitingSteps = (run) =>
5160
5404
  .map((s, i) => ({ step: s, index: i }))
5161
5405
  .filter(({ step }) => step.status === "awaiting-approval" && step.waitFor !== "event");
5162
5406
 
5407
+ /** The steps of a run parked on `wait: event` — what `approve --payload` releases. */
5408
+ const eventSteps = (run) =>
5409
+ (run.steps ?? [])
5410
+ .map((s, i) => ({ step: s, index: i }))
5411
+ .filter(({ step }) => step.status === "awaiting-approval" && step.waitFor === "event");
5412
+
5163
5413
  // ------------------------------------------------------------- approvals
5164
5414
 
5165
5415
  /**
@@ -5252,17 +5502,23 @@ async function decideCmd(decision, runId, flags, layout) {
5252
5502
  }
5253
5503
  const ws = await workspaceOfRun(url, flags, layout, runId);
5254
5504
  const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
5255
- const waiting = waitingSteps(run);
5505
+ // --payload releases a `wait: event` step as its event URL would, so those
5506
+ // are the steps it chooses from; every other decision is a person's gate.
5507
+ const payload = flags.payload === undefined ? undefined : eventPayload(flags.payload);
5508
+ if (payload !== undefined && decision !== "approve") throw new Error("--payload releases a `wait: event` step — it cannot go with reject");
5509
+ const waiting = payload === undefined ? waitingSteps(run) : eventSteps(run);
5256
5510
  if (!waiting.length) {
5257
5511
  throw new Error(
5258
- `${runId} in ${ws} is ${run.status} — no step of it is waiting on a person. \`foldrun approvals\` lists the ones that are`,
5512
+ payload === undefined
5513
+ ? `${runId} in ${ws} is ${run.status} — no step of it is waiting on a person. \`foldrun approvals\` lists the ones that are`
5514
+ : `${runId} in ${ws} is ${run.status} — no step of it is waiting on \`wait: event\`, which is what --payload releases`,
5259
5515
  );
5260
5516
  }
5261
5517
 
5262
5518
  const only = flags.step === undefined ? null : Number(flags.step) - 1;
5263
5519
  if (only !== null && !waiting.some(({ index }) => index === only)) {
5264
5520
  throw new Error(
5265
- `step ${flags.step} of ${runId} is not waiting on a person — ${waiting.map(({ index }) => index + 1).join(", ")} ${waiting.length === 1 ? "is" : "are"}`,
5521
+ `step ${flags.step} of ${runId} is not waiting on ${payload === undefined ? "a person" : "`wait: event`"} — ${waiting.map(({ index }) => index + 1).join(", ")} ${waiting.length === 1 ? "is" : "are"}`,
5266
5522
  );
5267
5523
  }
5268
5524
  const chosen = only === null ? waiting : waiting.filter(({ index }) => index === only);
@@ -5287,7 +5543,12 @@ async function decideCmd(decision, runId, flags, layout) {
5287
5543
  return 1;
5288
5544
  }
5289
5545
 
5290
- const body = { decision, ...(note ? { note, ...(decision === "reject" ? { reason: note } : {}) } : {}), ...(only === null ? {} : { step: only }) };
5546
+ const body = {
5547
+ decision,
5548
+ ...(note ? { note, ...(decision === "reject" ? { reason: note } : {}) } : {}),
5549
+ ...(only === null ? {} : { step: only }),
5550
+ ...(payload === undefined ? {} : { payload }),
5551
+ };
5291
5552
  const answer = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/approve`, {
5292
5553
  method: "POST",
5293
5554
  body: JSON.stringify(body),
@@ -5300,6 +5561,23 @@ async function decideCmd(decision, runId, flags, layout) {
5300
5561
  return 0;
5301
5562
  }
5302
5563
 
5564
+ /** `--payload` for a `wait: event` step: text, or `@file`; JSON is sent as
5565
+ * JSON, anything else as the text it is — what the event URL would take. */
5566
+ function eventPayload(value) {
5567
+ if (value === true) throw new Error("--payload needs a value — text, JSON, or @file");
5568
+ let text = String(value);
5569
+ if (text.startsWith("@")) {
5570
+ const file = path.resolve(text.slice(1));
5571
+ if (!fs.existsSync(file)) throw new Error(`--payload ${text}: no such file`);
5572
+ text = fs.readFileSync(file, "utf8");
5573
+ }
5574
+ try {
5575
+ return JSON.parse(text);
5576
+ } catch {
5577
+ return text;
5578
+ }
5579
+ }
5580
+
5303
5581
  // ------------------------------------------------------------------ stop
5304
5582
 
5305
5583
  /**
@@ -6367,6 +6645,106 @@ function hasDescription(raw) {
6367
6645
  return /^\s+\S/.test(lines[at + 1] ?? "");
6368
6646
  }
6369
6647
 
6648
+ /** What an import leaves behind: what the agent accumulated, not what it is.
6649
+ * The same rule as core's agent-import.ts. */
6650
+ const NOT_IMPORTED = /^(memory|outputs|state|workspace|\.claude)(\/|$)|^\.claude\.json$/;
6651
+
6652
+ /**
6653
+ * `foldrun agent import <workspace>/<agent> [--as <name>]` — bring an agent
6654
+ * you already have in another workspace into this one: its agent.md and its
6655
+ * own skills/, scripts/, knowledge/ — never its memory. With `--to <ws>` it
6656
+ * is the platform's POST /api/workspaces/<ws>/agents/import; without, it
6657
+ * copies between workspaces of this account folder, into the one you stand
6658
+ * in (or `--workspace <name>`).
6659
+ */
6660
+ async function agentImportCmd(positional, flags, layout) {
6661
+ // --list: what could be imported into a deployed workspace — every other
6662
+ // workspace this key may read, and its agents (GET …/agents/import).
6663
+ if (flags.list === true) {
6664
+ const url = platformFor(flags, "agent import --list");
6665
+ const ws = platformWorkspace(flags, layout, "agent import --list");
6666
+ const { workspaces = [] } = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/agents/import`);
6667
+ if (flags.json === true) {
6668
+ console.log(JSON.stringify(workspaces, null, 2));
6669
+ return 0;
6670
+ }
6671
+ if (!workspaces.length) {
6672
+ console.log(`\n ${c.dim(`nothing to import into ${ws} — no other workspace you can read has an agent`)}\n`);
6673
+ return 0;
6674
+ }
6675
+ const w = Math.max(...workspaces.flatMap((x) => (x.agents ?? []).map((a) => `${x.name}/${a.name}`.length)));
6676
+ console.log();
6677
+ for (const x of workspaces) {
6678
+ for (const a of x.agents ?? []) console.log(` ${c.bold(pad(`${x.name}/${a.name}`, w))} ${c.dim(firstLine(a.description ?? "", 80))}`);
6679
+ }
6680
+ console.log(`\n ${c.dim(`foldrun agent import <workspace>/<agent> --to ${ws} [--as <name>] copies one in`)}\n`);
6681
+ return 0;
6682
+ }
6683
+ const ref = positional[1];
6684
+ const m = typeof ref === "string" ? /^([^/]+)\/([^/]+)$/.exec(ref) : null;
6685
+ if (!m) throw new Error("which agent? `foldrun agent import <workspace>/<agent> [--as <name>] [--to <workspace>]`");
6686
+ const [, from, agent] = m;
6687
+ const as = typeof flags.as === "string" && flags.as.trim() ? flags.as.trim() : null;
6688
+
6689
+ if (typeof flags.to === "string") {
6690
+ const url = platformFor(flags, "agent import");
6691
+ const body = await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(flags.to)}/agents/import`, {
6692
+ method: "POST",
6693
+ body: JSON.stringify({ from, agent, ...(as ? { as } : {}) }),
6694
+ });
6695
+ console.log(`\n ${c.green("✓")} ${c.bold(body.name)} ${c.dim(`from ${from} → ${flags.to}`)}`);
6696
+ for (const f of body.files ?? []) console.log(` ${c.dim(f)}`);
6697
+ for (const w of body.warnings ?? []) console.log(` ${c.amber("!")} ${w}`);
6698
+ console.log();
6699
+ return 0;
6700
+ }
6701
+
6702
+ if (!layout.workspacesDir) {
6703
+ throw new Error("not in an account folder — pass --to <workspace> to import on the platform");
6704
+ }
6705
+ const name = as ?? agent;
6706
+ assertName(name);
6707
+ const src = path.join(layout.workspacesDir, from, "agents", agent);
6708
+ if (!fs.existsSync(path.join(src, "agent.md"))) {
6709
+ throw new Error(`no agent "${agent}" in ${from} — ${layout.workspaces.includes(from) ? "" : `this account has ${layout.workspaces.join(", ") || "no workspaces"}`}`.replace(/ — $/, ""));
6710
+ }
6711
+ const target =
6712
+ typeof flags.workspace === "string"
6713
+ ? path.join(layout.workspacesDir, flags.workspace)
6714
+ : layout.workspaceDir && layout.kind !== "empty"
6715
+ ? layout.workspaceDir
6716
+ : null;
6717
+ if (!target || !fs.existsSync(target)) throw new Error("into which workspace? cd into it, or --workspace <name>");
6718
+ if (path.resolve(target) === path.resolve(path.join(layout.workspacesDir, from)) && name === agent) {
6719
+ throw new Error(`${agent} is already in ${from} — give the copy another name with --as`);
6720
+ }
6721
+ const dest = path.join(target, "agents", name);
6722
+ if (fs.existsSync(dest)) throw new Error(`${path.basename(target)} already has an agent called "${name}" — import it under another name with --as`);
6723
+
6724
+ const written = [];
6725
+ const walk = (dir, rel = "") => {
6726
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
6727
+ const r = rel ? `${rel}/${e.name}` : e.name;
6728
+ if (NOT_IMPORTED.test(r) || e.isSymbolicLink()) continue;
6729
+ if (e.isDirectory()) walk(path.join(dir, e.name), r);
6730
+ else if (e.isFile()) {
6731
+ let content = fs.readFileSync(path.join(dir, e.name));
6732
+ if (r === "agent.md" && name !== agent) {
6733
+ content = Buffer.from(content.toString("utf8").replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`));
6734
+ }
6735
+ fs.mkdirSync(path.dirname(path.join(dest, r)), { recursive: true });
6736
+ fs.writeFileSync(path.join(dest, r), content);
6737
+ written.push(`agents/${name}/${r}`);
6738
+ }
6739
+ }
6740
+ };
6741
+ walk(src);
6742
+ console.log(`\n ${c.green("✓")} ${c.bold(name)} ${c.dim(`from ${from} → ${path.basename(target)}`)}`);
6743
+ for (const f of written) console.log(` ${c.dim(f)}`);
6744
+ console.log(` ${c.dim("its memory stayed in")} ${from}${c.dim(" — `foldrun check` names any tool, skill or secret it uses that this workspace lacks")}\n`);
6745
+ return 0;
6746
+ }
6747
+
6370
6748
  /**
6371
6749
  * `foldrun agent link <agent> --subagent <worker> | --consult <agent> |
6372
6750
  * --can-ask`, and `unlink` — an agent's team, edited in its frontmatter
@@ -6560,6 +6938,27 @@ async function flowShowCmd(positional, flags, layout) {
6560
6938
  return errors ? 1 : 0;
6561
6939
  }
6562
6940
 
6941
+ /**
6942
+ * `foldrun flow hook <flow>` — the flow's webhook URL as it is now, changing
6943
+ * nothing. Before this the only way to learn it from here was rotate-hook,
6944
+ * which broke the URL whoever was already posting to it had.
6945
+ */
6946
+ async function flowHookCmd(positional, flags, layout) {
6947
+ const flow = positional[1];
6948
+ if (!flow) throw new Error("which flow? `foldrun flow hook <flow> --to <workspace>`");
6949
+ const url = platformFor(flags, "flow hook");
6950
+ const ws = platformWorkspace(flags, layout, `flow hook ${flow}`);
6951
+ const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/hooks/${enc(flow)}`);
6952
+ const full = r.url ?? new URL(r.path, url).toString();
6953
+ if (flags.json) {
6954
+ console.log(JSON.stringify({ flow, url: full, path: r.path }));
6955
+ return 0;
6956
+ }
6957
+ console.log(`\n ${c.bold(full)}`);
6958
+ console.log(` ${c.dim("a credential — whoever has it can start this flow; `foldrun flow rotate-hook " + flow + "` replaces it")}\n`);
6959
+ return 0;
6960
+ }
6961
+
6563
6962
  /**
6564
6963
  * `foldrun flow rotate-hook <flow>` — a new webhook URL for one flow. The
6565
6964
  * old one stops working the moment this returns, so it asks first.
@@ -6652,9 +7051,20 @@ function bulkFilter(flags) {
6652
7051
  if (typeof flags.status === "string") body.status = flags.status.split(",").map((s) => s.trim()).filter(Boolean);
6653
7052
  if (typeof flags.flow === "string") body.flow = flags.flow;
6654
7053
  if (flags.since !== undefined) body.since = new Date(Date.now() - parseSince(flags.since)).toISOString();
7054
+ if (flags.until !== undefined) body.until = untilInstant(flags.until);
6655
7055
  return body;
6656
7056
  }
6657
- const hasBulkFilter = (flags) => typeof flags.status === "string" || typeof flags.flow === "string" || flags.since !== undefined;
7057
+ const hasBulkFilter = (flags) => typeof flags.status === "string" || typeof flags.flow === "string" || flags.since !== undefined || flags.until !== undefined;
7058
+
7059
+ /** `--until 2h` (runs started at least that long ago) or a date — as the
7060
+ * ISO instant runs/bulk reads. A value that is neither refuses: a clause
7061
+ * that silently fell out would widen the filter. */
7062
+ function untilInstant(spec) {
7063
+ if (/^\d+(\.\d+)?\s*[smhdw]$/i.test(String(spec).trim())) return new Date(Date.now() - parseSince(spec)).toISOString();
7064
+ const t = typeof spec === "string" ? Date.parse(spec) : NaN;
7065
+ if (Number.isNaN(t)) throw new Error(`--until wants a span like 2h (runs started before then) or a date, 2026-10-05T09:00 — not "${spec}"`);
7066
+ return new Date(t).toISOString();
7067
+ }
6658
7068
 
6659
7069
  /**
6660
7070
  * `foldrun stop|rerun --status … --since … --flow …` — one decision, many
@@ -6791,6 +7201,100 @@ async function remoteBytes(url, flags, apiPath, what) {
6791
7201
  return { bytes: Buffer.from(await res.arrayBuffer()), name };
6792
7202
  }
6793
7203
 
7204
+ /**
7205
+ * `foldrun export [workspace] [--flow <name> | --agent <name>]` — a deployed
7206
+ * workspace, one flow or one agent as a .zip: the authored source it needs,
7207
+ * never memory, state, storage or a secret's value (GET …/export). The same
7208
+ * file the dashboard's Export downloads and `foldrun import` takes in.
7209
+ * @param {string[]} positional @param {Record<string, any>} flags
7210
+ */
7211
+ async function exportCmd(positional, flags, layout) {
7212
+ const url = platformFor(flags, "export");
7213
+ const ws = positional[0] ?? platformWorkspace(flags, layout, "export");
7214
+ if (typeof flags.flow === "string" && typeof flags.agent === "string") throw new Error("one at a time: --flow <name> or --agent <name>");
7215
+ const kind = typeof flags.flow === "string" ? "flow" : typeof flags.agent === "string" ? "agent" : "workspace";
7216
+ const name = kind === "flow" ? flags.flow : kind === "agent" ? flags.agent : null;
7217
+ const qs = name ? `?kind=${kind}&name=${enc(name)}` : "";
7218
+ const { bytes, name: served } = await remoteBytes(url, flags, `/api/workspaces/${enc(ws)}/export${qs}`, `export ${name ? `${kind} ${name} from ` : ""}${ws}`);
7219
+ return writeDownload(bytes, served ?? (name ? `${ws}-${kind}-${name}.zip` : `${ws}.zip`), flags, name ? `${ws} · ${kind} ${name}` : ws);
7220
+ }
7221
+
7222
+ /**
7223
+ * `foldrun import <file.zip> [--to <workspace>]` — take a package in: shows
7224
+ * what it adds, would replace and still needs (POST …/import?dryRun=1), then
7225
+ * asks, then applies. Never deletes; replacing a file needs --overwrite. A
7226
+ * workspace package into a name that does not exist makes that workspace.
7227
+ * @param {string[]} positional @param {Record<string, any>} flags
7228
+ */
7229
+ async function importCmd(positional, flags, layout) {
7230
+ const file = positional[0];
7231
+ if (!file) throw new Error("which package? `foldrun import <file.zip> [--to <workspace>]` — `foldrun export` or the dashboard's Export makes one");
7232
+ if (!fs.existsSync(file)) throw new Error(`${file} does not exist`);
7233
+ const url = platformFor(flags, "import");
7234
+ const ws = platformWorkspace(flags, layout, "import");
7235
+ const bytes = fs.readFileSync(file);
7236
+ const token = tokenFor(url, flags);
7237
+ const send = async (query) => {
7238
+ const res = await remoteFetch(
7239
+ url,
7240
+ `/api/workspaces/${enc(ws)}/import${query}`,
7241
+ { method: "POST", body: bytes, headers: { "content-type": "application/zip" } },
7242
+ { token, seconds: timeoutSeconds(flags) },
7243
+ );
7244
+ const text = await res.text();
7245
+ let body = {};
7246
+ try {
7247
+ body = text.trim() ? JSON.parse(text) : {};
7248
+ } catch {
7249
+ /* not JSON — the status says enough */
7250
+ }
7251
+ if (!res.ok) throw new HttpError(`${body.error ?? `import → HTTP ${res.status}`}${res.status === 401 || res.status === 403 ? refusedHint(url, flags) : ""}`, res.status, body);
7252
+ return body;
7253
+ };
7254
+
7255
+ const plan = await send("?dryRun=1");
7256
+ const what = `${plan.kind === "workspace" ? "workspace" : `${plan.kind} ${plan.name}`}${plan.from ? ` from ${plan.from}` : ""}`;
7257
+ if (flags.json === true && flags["dry-run"] === true) {
7258
+ console.log(JSON.stringify(plan, null, 2));
7259
+ return 0;
7260
+ }
7261
+ console.log(`\n ${c.bold(what)} → ${c.bold(ws)}${plan.creates ? c.dim(" (a new workspace)") : ""}`);
7262
+ console.log(` ${plan.added.length} new · ${plan.unchanged.length} identical · ${plan.overwritten.length ? c.amber(`${plan.overwritten.length} would be replaced`) : "0 replaced"}`);
7263
+ for (const p of plan.overwritten.slice(0, 20)) console.log(` ${c.amber("~")} ${p}`);
7264
+ if (plan.skipped?.length) console.log(` ${c.dim(`${plan.skipped.length} not imported (memory, state, or outside the authored folders): ${plan.skipped.slice(0, 6).join(", ")}${plan.skipped.length > 6 ? " …" : ""}`)}`);
7265
+ const needs = [
7266
+ ["secrets to set", plan.needs?.secrets],
7267
+ ["tools not here or in the library", plan.needs?.tools],
7268
+ ["skills not found", plan.needs?.skills],
7269
+ ["scripts not found", plan.needs?.scripts],
7270
+ ["agents it names that are not here", plan.needs?.agents],
7271
+ ["flows it runs that are not here", plan.needs?.flows],
7272
+ ].filter(([, v]) => v?.length);
7273
+ for (const [k, v] of needs) console.log(` ${c.amber("!")} ${k}: ${v.join(", ")}`);
7274
+
7275
+ if (flags["dry-run"] === true) {
7276
+ console.log(`\n ${c.dim("(--dry-run: nothing written)")}\n`);
7277
+ return 0;
7278
+ }
7279
+ if (plan.added.length + plan.overwritten.length === 0) {
7280
+ console.log(`\n ${c.dim("nothing to import — every file is already identical")}\n`);
7281
+ return 0;
7282
+ }
7283
+ if (plan.overwritten.length && flags.overwrite !== true) {
7284
+ console.log(`\n ${c.dim(`nothing written — ${plan.overwritten.length} file${plan.overwritten.length === 1 ? "" : "s"} would be replaced; --overwrite replaces them (History can restore them)`)}\n`);
7285
+ return 1;
7286
+ }
7287
+ if (!(await confirmed(flags, "import", `Import into ${ws}? [y/N] `))) {
7288
+ console.log(`\n ${c.dim("nothing written")}\n`);
7289
+ return 0;
7290
+ }
7291
+ const done = await send(flags.overwrite === true ? "?overwrite=1" : "");
7292
+ console.log(`\n ${c.green("✓")} ${(done.written ?? []).length} file${(done.written ?? []).length === 1 ? "" : "s"} written to ${c.bold(ws)}${done.revision ? c.dim(` revision ${done.revision}`) : ""}`);
7293
+ if (needs.length) console.log(` ${c.dim("set what it still needs before its first run — `foldrun secrets set <NAME>` for secrets")}`);
7294
+ console.log();
7295
+ return 0;
7296
+ }
7297
+
6794
7298
  /**
6795
7299
  * `foldrun report <run-id> get <agent>/<path>` — one file a run archived:
6796
7300
  * whatever an agent left in outputs/, copied under the run when it ended.
@@ -6853,7 +7357,7 @@ async function observeCmd(flags, layout) {
6853
7357
  const obs = (
6854
7358
  await pool(names, 8, async (ws) => {
6855
7359
  try {
6856
- return await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/observe?since=${since}`);
7360
+ return await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/observe?days=${since}`);
6857
7361
  } catch (err) {
6858
7362
  if (typeof flags.to === "string") throw err;
6859
7363
  unreachable.push([ws, explain(err)]);
@@ -7058,7 +7562,27 @@ async function billingVerbCmd(positional, flags) {
7058
7562
  return 0;
7059
7563
  }
7060
7564
 
7061
- throw new Error(`unknown billing verb "${verb}" — statement, wallet, details or portal (bare \`foldrun billing\` is the balance)`);
7565
+ if (verb === "credit") {
7566
+ // A manual credit (POST /api/billing): what a self-hosted or dev install
7567
+ // without Stripe uses to put money on the balance. Once billing is live
7568
+ // the platform refuses it — top-ups go through checkout.
7569
+ const amount = Number(sub);
7570
+ if (!(amount > 0) || !Number.isFinite(amount)) throw new Error("`foldrun billing credit <usd> [--note \"…\"]` — a positive amount in USD");
7571
+ let r;
7572
+ try {
7573
+ r = await remoteCall(url, flags, "/api/billing", { method: "POST", body: JSON.stringify({ usd: amount, ...(typeof flags.note === "string" ? { note: flags.note } : {}) }) });
7574
+ } catch (err) {
7575
+ if (err instanceof HttpError && err.status === 403 && /checkout/.test(err.message)) {
7576
+ throw new Error(`billing is live on this platform, so credit is not added by hand — \`foldrun billing top-up ${amount}\` (${err.message})`);
7577
+ }
7578
+ if (err instanceof HttpError && err.status === 403) throw new Error(`billing credit is the account owner's alone (billing:manage) — ${err.message}`);
7579
+ throw err;
7580
+ }
7581
+ const bal = r.balanceUsd;
7582
+ console.log(`\n ${c.green("✓")} credited ${usd(amount)}${bal !== undefined ? c.dim(` · balance ${usd(bal)}`) : ""}${typeof flags.note === "string" ? c.dim(` · "${flags.note}"`) : ""}\n`);
7583
+ return 0;
7584
+ }
7585
+ throw new Error(`unknown billing verb "${verb}" — statement, wallet, details, portal or credit (bare \`foldrun billing\` is the balance)`);
7062
7586
  }
7063
7587
 
7064
7588
  /** The defaults a workspace's AGENTS.md sets, read from its source. */
@@ -7253,6 +7777,49 @@ async function notificationsCmd(positional, flags) {
7253
7777
  return 0;
7254
7778
  }
7255
7779
 
7780
+ /** Why a flag is what it is, as `foldrun flags` says it. */
7781
+ const FLAG_SOURCES = {
7782
+ account: "set for this account",
7783
+ global: "set for every account",
7784
+ rollout: "staged rollout",
7785
+ default: "default",
7786
+ };
7787
+
7788
+ /**
7789
+ * `foldrun flags` — the feature flags as they stand for this account: on or
7790
+ * off, and what decided it (this account's override, the install-wide
7791
+ * setting, a percentage rollout, or the default). Read-only: only the
7792
+ * platform's super admin changes a flag, in the console, with a session —
7793
+ * an API key never reaches those routes, so the CLI has no verb for them.
7794
+ */
7795
+ async function flagsCmd(positional, flags) {
7796
+ const url = platformFor(flags, "flags");
7797
+ if (positional[0]) throw new Error("`foldrun flags` lists this account's feature flags — they are changed by the platform's super admin, not from here");
7798
+ const r = await remoteCall(url, flags, "/api/account/flags");
7799
+ if (flags.json === true) {
7800
+ console.log(JSON.stringify(r, null, 2));
7801
+ return 0;
7802
+ }
7803
+ const list = r.flags ?? [];
7804
+ if (!list.length) {
7805
+ console.log(`\n ${c.dim("no feature flags on this platform")}\n`);
7806
+ return 0;
7807
+ }
7808
+ const w = Math.max(...list.map((f) => f.name.length));
7809
+ const sw = Math.max(...list.map((f) => why(f).length));
7810
+ console.log("");
7811
+ for (const f of list) {
7812
+ const state = f.on ? c.green("on ") : c.dim("off");
7813
+ console.log(` ${pad(f.name, w)} ${state} ${c.dim(pad(why(f), sw))} ${f.description}`);
7814
+ }
7815
+ console.log(`\n ${c.dim("read-only — the platform's super admin turns flags on and off")}\n`);
7816
+ return 0;
7817
+ function why(f) {
7818
+ const base = FLAG_SOURCES[f.source] ?? String(f.source ?? "");
7819
+ return f.source === "rollout" && typeof f.rollout === "number" ? `${base} ${f.rollout}%` : base;
7820
+ }
7821
+ }
7822
+
7256
7823
  /**
7257
7824
  * `foldrun history [path] --to <workspace>` — every change to a deployed
7258
7825
  * workspace, newest first: who, what, which files. `--id <revision>` shows
@@ -7425,7 +7992,27 @@ async function backupsCmd(positional, flags) {
7425
7992
  console.log(` ${request.notified ? c.dim("the platform team has been emailed; nothing is restored until they do it") : c.yellow("recorded, but the email to the platform team did not send — contact them as well")}\n`);
7426
7993
  return 0;
7427
7994
  }
7428
- if (verb !== "ls" && verb !== "list") throw new Error(`unknown backups verb "${verb}" — ls, request`);
7995
+ if (verb === "requests") {
7996
+ // Every restore this account has asked the platform team for, and where
7997
+ // each stands (GET /api/account/backups/requests) — `backups` shows five.
7998
+ const { requests = [] } = await remoteCall(url, flags, "/api/account/backups/requests");
7999
+ if (flags.json === true) {
8000
+ console.log(JSON.stringify(requests, null, 2));
8001
+ return 0;
8002
+ }
8003
+ if (!requests.length) {
8004
+ console.log(`\n ${c.dim("no restore requests — foldrun backups request --what runs --at \"<when>\" asks for one")}\n`);
8005
+ return 0;
8006
+ }
8007
+ console.log();
8008
+ for (const r of requests) {
8009
+ console.log(` ${when(r.at)} ${c.bold(r.status)} ${r.what} · ${r.workspace ?? "every workspace"} · to ${r.target} ${c.dim(`${r.id} · ${r.by}${r.notified ? "" : " · platform team not emailed"}`)}`);
8010
+ if (r.note) console.log(` ${c.dim(firstLine(r.note, 100))}`);
8011
+ }
8012
+ console.log(`\n ${c.dim(`${requests.length} request${requests.length === 1 ? "" : "s"} · the platform team restores; nothing here changes until they do`)}\n`);
8013
+ return 0;
8014
+ }
8015
+ if (verb !== "ls" && verb !== "list") throw new Error(`unknown backups verb "${verb}" — ls, request, requests`);
7429
8016
  const b = await remoteCall(url, flags, "/api/account/backups");
7430
8017
  if (flags.json === true) {
7431
8018
  console.log(JSON.stringify(b, null, 2));
@@ -7908,7 +8495,7 @@ async function scheduleTickCmd(flags) {
7908
8495
  return 0;
7909
8496
  }
7910
8497
 
7911
- const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge"];
8498
+ const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge", "memory"];
7912
8499
 
7913
8500
  /**
7914
8501
  * `foldrun library [ls [kind]] | cat <kind>/<path> | put <kind>/<path> | rm
@@ -7935,7 +8522,7 @@ async function libraryCmd(positional, flags) {
7935
8522
  console.log(` ${c.bold(kinds[n])} ${c.dim(`${entries.length}`)}`);
7936
8523
  for (const e of entries) console.log(` ${pad(e.name ?? e.path ?? "", 28)} ${c.dim(firstLine(e.description ?? "", 70))}`);
7937
8524
  });
7938
- console.log(`\n ${c.dim("foldrun library cat <kind>/<path> · put <kind>/<path> (--file, else stdin) · rm <kind>/<path>")}\n`);
8525
+ console.log(`\n ${c.dim("foldrun library cat <kind>/<path> · put <kind>/<path> (--file, else stdin) · new <kind>/<name> · rm <kind>/<path>")}\n`);
7939
8526
  return 0;
7940
8527
  }
7941
8528
  if (verb === "cat") {
@@ -7954,6 +8541,17 @@ async function libraryCmd(positional, flags) {
7954
8541
  console.log(`\n ${c.green("✓")} library/${kind}/${rel} ${c.dim(`${humanBytes(Buffer.byteLength(content))} — every workspace in the account sees it`)}\n`);
7955
8542
  return 0;
7956
8543
  }
8544
+ if (verb === "new") {
8545
+ // The Library page's New (POST /api/library/<kind>): the platform writes
8546
+ // the template — a blank one, a tool of a transport, or a gallery entry
8547
+ // under this name (--template).
8548
+ const { kind, rel: name } = split(spec);
8549
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) throw new Error(`"${name}" is not a name — kebab-case only, e.g. skills/house-style`);
8550
+ const body = { name, ...(typeof flags.template === "string" ? { template: flags.template } : {}), ...(typeof flags.language === "string" ? { language: flags.language } : {}) };
8551
+ const r = await remoteCall(url, flags, `/api/library/${kind}`, { method: "POST", body: JSON.stringify(body) });
8552
+ console.log(`\n ${c.green("✓")} library/${kind}/${r.path ?? name} ${c.dim("— every workspace in the account sees it")}\n`);
8553
+ return 0;
8554
+ }
7957
8555
  if (verb === "rm") {
7958
8556
  const { kind, rel } = split(spec);
7959
8557
  if (!(await sureToDelete(flags, "library rm", `library/${kind}/${rel} — every workspace that names it loses it`))) return 1;
@@ -7961,7 +8559,7 @@ async function libraryCmd(positional, flags) {
7961
8559
  console.log(`\n ${c.green("✓")} removed library/${kind}/${rel}\n`);
7962
8560
  return 0;
7963
8561
  }
7964
- throw new Error(`unknown library verb "${verb}" — ls [kind], cat, put, rm`);
8562
+ throw new Error(`unknown library verb "${verb}" — ls [kind], cat, put, new, rm`);
7965
8563
  }
7966
8564
 
7967
8565
  /**
@@ -8290,6 +8888,95 @@ async function findCmd(positional, flags) {
8290
8888
  }
8291
8889
 
8292
8890
  /** `foldrun workspaces demo` — "Try the demo pipeline": the demo workspace, made on the platform. */
8891
+ /**
8892
+ * `foldrun workspaces show <name>` — what is deployed in one workspace (GET
8893
+ * /api/workspaces/<name>): its agents, its flows and how they fire, and its
8894
+ * files, counted by top-level folder.
8895
+ */
8896
+ async function workspacesShowCmd(name, flags) {
8897
+ const url = platformFor(flags, "workspaces show");
8898
+ if (!name) throw new Error("which workspace? `foldrun workspaces show <name>`");
8899
+ const r = await remoteCall(url, flags, `/api/workspaces/${enc(name)}`);
8900
+ if (flags.json === true) {
8901
+ console.log(JSON.stringify(r, null, 2));
8902
+ return 0;
8903
+ }
8904
+ const agents = r.agents ?? [];
8905
+ const flows = r.flows ?? [];
8906
+ const files = r.files ?? [];
8907
+ console.log(`\n ${c.bold(r.name ?? name)} ${c.dim(`on ${url} — ${agents.length} agent${agents.length === 1 ? "" : "s"}, ${flows.length} flow${flows.length === 1 ? "" : "s"}, ${files.length} file${files.length === 1 ? "" : "s"}`)}`);
8908
+ if (agents.length) {
8909
+ const w = Math.max(...agents.map((a) => a.name.length));
8910
+ console.log(`\n ${c.dim("agents")}`);
8911
+ for (const a of agents) console.log(` ${c.bold(pad(a.name, w))} ${pad(a.model ?? "", 8)} ${c.dim(firstLine(a.description ?? "", 70))}`);
8912
+ }
8913
+ if (flows.length) {
8914
+ const w = Math.max(...flows.map((f) => f.name.length));
8915
+ console.log(`\n ${c.dim("flows")}`);
8916
+ for (const f of flows) {
8917
+ const fires = f.trigger === "schedule" && f.schedule ? `schedule ${f.schedule}${f.timezone ? ` ${f.timezone}` : ""}` : (f.trigger ?? "manual");
8918
+ console.log(` ${c.bold(pad(f.name, w))} ${c.dim(fires)}`);
8919
+ }
8920
+ }
8921
+ if (files.length) {
8922
+ const by = new Map();
8923
+ for (const f of files) {
8924
+ const top = f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f;
8925
+ by.set(top, (by.get(top) ?? 0) + 1);
8926
+ }
8927
+ console.log(`\n ${c.dim("files")}`);
8928
+ console.log(` ${[...by].map(([k, n]) => (n > 1 || k.endsWith("/") ? `${k} ${c.dim(String(n))}` : k)).join(" ")}`);
8929
+ }
8930
+ console.log(`\n ${c.dim(`foldrun source ls --to ${r.name ?? name} lists every file · --json for the whole document`)}\n`);
8931
+ return 0;
8932
+ }
8933
+
8934
+ /**
8935
+ * `foldrun eval new <name> --to <workspace>` — the Evals page's New: the
8936
+ * platform writes an eval whose agent is the workspace's first (POST
8937
+ * …/evals). Edit the cases, then `foldrun eval <name> --to` runs it.
8938
+ */
8939
+ async function evalNewCmd(name, flags, layout) {
8940
+ const url = platformFor(flags, "eval new");
8941
+ const ws = platformWorkspace(flags, layout, `eval new ${name}`);
8942
+ if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) throw new Error(`"${name}" is not an eval name — kebab-case only, e.g. researcher-quality`);
8943
+ const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals`, { method: "POST", body: JSON.stringify({ name }) });
8944
+ console.log(`\n ${c.green("created")} eval ${c.bold(name)} in ${ws} ${c.dim(`on ${url}`)}`);
8945
+ console.log(` ${c.dim(r.path ?? `evals/${name}.md`)}`);
8946
+ console.log(`\n ${c.dim(`its cases are placeholders: foldrun source cat ${r.path ?? `evals/${name}.md`} --to ${ws}, then foldrun eval ${name} --to ${ws}`)}\n`);
8947
+ return 0;
8948
+ }
8949
+
8950
+ /**
8951
+ * `foldrun team` — who is in the account, their role and which workspaces
8952
+ * they may open (GET /api/team). Read-only on purpose: inviting, changing a
8953
+ * role, removing someone and transferring the account are a person's acts,
8954
+ * and the platform refuses them from a key.
8955
+ */
8956
+ async function teamCmd(positional, flags) {
8957
+ if (positional[0]) {
8958
+ throw new Error("`foldrun team` lists the members — invites, role changes, removals and transfer are done by a signed-in person in Settings → Team; the platform refuses them from a key");
8959
+ }
8960
+ const url = platformFor(flags, "team");
8961
+ const { members = [] } = await remoteCall(url, flags, "/api/team");
8962
+ if (flags.json === true) {
8963
+ console.log(JSON.stringify(members, null, 2));
8964
+ return 0;
8965
+ }
8966
+ if (!members.length) {
8967
+ console.log(`\n ${c.dim("no members visible to this key")}\n`);
8968
+ return 0;
8969
+ }
8970
+ const w = Math.max(...members.map((m) => m.email.length));
8971
+ console.log();
8972
+ for (const m of members) {
8973
+ const scope = m.workspaces === null || m.workspaces === undefined ? "every workspace" : m.workspaces.length ? m.workspaces.join(", ") : "no workspaces";
8974
+ console.log(` ${c.bold(pad(m.email, w))} ${pad(m.owner ? "owner" : m.role, 7)} ${c.dim(scope)} ${c.dim(m.createdAt ? `joined ${String(m.createdAt).slice(0, 10)}` : "")}`);
8975
+ }
8976
+ console.log(`\n ${c.dim(`${members.length} member${members.length === 1 ? "" : "s"} · invites, roles and removals: Settings → Team in the dashboard (a person's, never a key's)`)}\n`);
8977
+ return 0;
8978
+ }
8979
+
8293
8980
  async function workspacesDemoCmd(flags) {
8294
8981
  const url = platformFor(flags, "workspaces demo");
8295
8982
  const r = await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ demo: true }) });
@@ -8496,10 +9183,15 @@ export async function run(command, positional, flags, workspace, layout) {
8496
9183
  : check(workspace, flags);
8497
9184
  case "pull":
8498
9185
  return pullCmd(layout, flags, positional[0]);
9186
+ case "export":
9187
+ return exportCmd(positional, flags, layout);
9188
+ case "import":
9189
+ return importCmd(positional, flags, layout);
8499
9190
  case "status":
8500
9191
  return statusCmd(layout, flags, positional[0]);
8501
9192
  case "workspaces":
8502
9193
  if (positional[0] === "demo") return workspacesDemoCmd(flags);
9194
+ if (positional[0] === "show") return workspacesShowCmd(positional[1], flags);
8503
9195
  return workspacesCmd(positional, flags, layout);
8504
9196
  case "extract":
8505
9197
  return extract(workspace, flags);
@@ -8509,6 +9201,9 @@ export async function run(command, positional, flags, workspace, layout) {
8509
9201
  needsOne(layout, "run");
8510
9202
  return runTarget(positional[0], flags);
8511
9203
  case "eval":
9204
+ // `eval new <name>` makes one on the platform; an eval called "new"
9205
+ // still runs as `foldrun eval new` with nothing after it.
9206
+ if (positional[0] === "new" && positional[1]) return evalNewCmd(positional[1], flags, layout);
8512
9207
  // --to names a deployed workspace: its evals run there.
8513
9208
  if (typeof flags.to === "string") return remoteEvalsCmd(positional[0], flags, layout);
8514
9209
  needsOne(layout, "eval");
@@ -8528,6 +9223,8 @@ export async function run(command, positional, flags, workspace, layout) {
8528
9223
  return webhooksCmd(positional, flags, layout);
8529
9224
  case "notifications":
8530
9225
  return notificationsCmd(positional, flags);
9226
+ case "flags":
9227
+ return flagsCmd(positional, flags);
8531
9228
  case "history":
8532
9229
  if (positional[0] === "restore") return historyRestoreCmd(positional, flags, layout);
8533
9230
  return historyCmd(positional, flags, layout);
@@ -8537,6 +9234,8 @@ export async function run(command, positional, flags, workspace, layout) {
8537
9234
  return restoreCmd(positional, flags);
8538
9235
  case "backups":
8539
9236
  return backupsCmd(positional, flags);
9237
+ case "team":
9238
+ return teamCmd(positional, flags);
8540
9239
  case "onboarding":
8541
9240
  return onboardingCmd(flags);
8542
9241
  case "probe":
@@ -8609,6 +9308,8 @@ export async function run(command, positional, flags, workspace, layout) {
8609
9308
  // `new` scaffolds one here; `run` runs one there. The noun is the same
8610
9309
  // thing in both cases, which is why they share a command.
8611
9310
  if (positional[0] === "link" || positional[0] === "unlink") return agentLinkCmd(positional, flags, layout);
9311
+ if (positional[0] === "import") return agentImportCmd(positional, flags, layout);
9312
+ if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("agents", flags, layout);
8612
9313
  return positional[0] === "run"
8613
9314
  ? agentRunCmd(positional, flags, layout)
8614
9315
  : scaffoldCmd("agents", positional, flags, layout);
@@ -8621,12 +9322,14 @@ export async function run(command, positional, flags, workspace, layout) {
8621
9322
  if (positional[0] === "show") return flowShowCmd(positional, flags, layout);
8622
9323
  if (positional[0] === "run") return invoke(positional[1], flags);
8623
9324
  if (positional[0] === "rotate-hook") return rotateHookCmd(positional, flags, layout);
9325
+ if (positional[0] === "hook") return flowHookCmd(positional, flags, layout);
8624
9326
  if (positional[0] === "set") return flowSetCmd(positional, flags, layout);
8625
9327
  if (positional[0] === "trigger") return flowTriggerCmd(positional, flags, layout);
8626
9328
  if (positional[0] === "move-step") return flowMoveStepCmd(positional, flags, layout);
8627
9329
  if (positional[0] === "copy-step") return flowCopyStepCmd(positional, flags, layout);
8628
9330
  if (positional[0] === "paste") return flowPasteCmd(positional, flags, layout);
8629
9331
  if (positional[0] === "draft") return flowDraftCmd(positional, flags, layout);
9332
+ if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("flows", flags, layout);
8630
9333
  return scaffoldCmd("flows", positional, flags, layout);
8631
9334
  case "tool":
8632
9335
  return positional[0] === "test"