foldrun 0.7.2 → 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/README.md +10 -7
- package/bin/foldrun.mjs +74 -32
- package/docs/agents.md +64 -4
- package/docs/api-usage.md +4 -3
- package/docs/api.md +29 -14
- package/docs/budgets-and-billing.md +4 -0
- package/docs/cli.md +66 -25
- package/docs/coding-agents.md +9 -3
- package/docs/deploying.md +5 -1
- package/docs/faq.md +5 -2
- package/docs/feature-flags.md +63 -0
- package/docs/flows.md +24 -5
- package/docs/getting-started.md +1 -0
- package/docs/providers.md +28 -0
- package/docs/runs.md +8 -3
- package/docs/tools.md +16 -1
- package/docs/versions.md +25 -0
- package/docs/workspaces.md +87 -0
- package/package.json +4 -4
- package/src/commands.mjs +786 -34
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
|
-
|
|
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
|
-
|
|
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"] }[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
|
-
|
|
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
|
|
@@ -1124,16 +1220,22 @@ function bundleDirs(workspace, kind) {
|
|
|
1124
1220
|
|
|
1125
1221
|
// ---------------------------------------------------------------- run
|
|
1126
1222
|
|
|
1127
|
-
//
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1223
|
+
// A model credential, named, and never the machine's own login by default:
|
|
1224
|
+
// an API key (ANTHROPIC_API_KEY), a gateway bearer (ANTHROPIC_AUTH_TOKEN),
|
|
1225
|
+
// or a Claude login token (CLAUDE_CODE_OAUTH_TOKEN, from `claude
|
|
1226
|
+
// setup-token`) — in that order, and the run says which (core
|
|
1227
|
+
// model-credential.ts). Before, the key was checked here and then never
|
|
1228
|
+
// reached the step, which ran on whatever login the machine had. An agent
|
|
1229
|
+
// that names its own `provider:` needs none of these — that is checked where
|
|
1230
|
+
// the agent is read. Terms: Anthropic does not allow products built on its
|
|
1231
|
+
// Agent SDK to offer claude.ai login unless it has approved them.
|
|
1232
|
+
const MODEL_CREDENTIALS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];
|
|
1132
1233
|
function assertCredentials() {
|
|
1133
|
-
if (
|
|
1234
|
+
if (MODEL_CREDENTIALS.some((v) => (process.env[v] ?? "").trim())) return;
|
|
1134
1235
|
throw new Error(
|
|
1135
|
-
"no
|
|
1136
|
-
" or
|
|
1236
|
+
"no model credential — set ANTHROPIC_API_KEY (an API key from console.anthropic.com),\n" +
|
|
1237
|
+
" or CLAUDE_CODE_OAUTH_TOKEN (a Claude login token: `claude setup-token`),\n" +
|
|
1238
|
+
" or give the agent its own `provider:`. `foldrun check` works without any.",
|
|
1137
1239
|
);
|
|
1138
1240
|
}
|
|
1139
1241
|
|
|
@@ -3321,6 +3423,20 @@ async function doctor(flags) {
|
|
|
3321
3423
|
// Set or unset, never the value: two of these are a key and a proxy URL
|
|
3322
3424
|
// that may carry one.
|
|
3323
3425
|
line(true, "env", ["FOLDRUN_URL", "FOLDRUN_TOKEN", "FOLDRUN_TIMEOUT", "HTTPS_PROXY"].map((v) => (env(v) ? `${v} set` : c.dim(`${v} unset`))).join(" · "));
|
|
3426
|
+
// Which model credential a local run would use — said, not judged: a
|
|
3427
|
+
// machine that only drives the platform needs none.
|
|
3428
|
+
try {
|
|
3429
|
+
const { resolveModelCredential } = await core();
|
|
3430
|
+
const m = resolveModelCredential(process.env);
|
|
3431
|
+
console.log(
|
|
3432
|
+
` ${m.kind === "none" ? c.dim("·") : c.green("✓")} ${"model".padEnd(9)} ` +
|
|
3433
|
+
(m.kind === "none"
|
|
3434
|
+
? c.dim("no credential for local runs — ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`")
|
|
3435
|
+
: `${m.label} — what \`foldrun run\` uses here`),
|
|
3436
|
+
);
|
|
3437
|
+
} catch {
|
|
3438
|
+
// core unresolvable is already its own line above
|
|
3439
|
+
}
|
|
3324
3440
|
|
|
3325
3441
|
let url;
|
|
3326
3442
|
let token;
|
|
@@ -3610,7 +3726,25 @@ async function sourceCmd(positional, flags) {
|
|
|
3610
3726
|
console.log(`\n ${c.green("✓")} removed ${a}\n`);
|
|
3611
3727
|
return 0;
|
|
3612
3728
|
}
|
|
3613
|
-
|
|
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`);
|
|
3614
3748
|
}
|
|
3615
3749
|
|
|
3616
3750
|
// ------------------------------------------------- the account, as a whole
|
|
@@ -3944,6 +4078,7 @@ async function reportRuntimes(url, workspace, flags, { waitSeconds = 0 } = {}) {
|
|
|
3944
4078
|
r.python && r.python !== true ? `python ${r.python}` : null,
|
|
3945
4079
|
...(r.packages ?? []),
|
|
3946
4080
|
...(r.npm ?? []).map((n) => `npm:${n}`),
|
|
4081
|
+
...(r.system ?? []).map((n) => `apt:${n}`),
|
|
3947
4082
|
].filter(Boolean).join(", ") || "python";
|
|
3948
4083
|
const who = c.dim(`(${(r.agents ?? []).join(", ")})`);
|
|
3949
4084
|
const mark =
|
|
@@ -4014,6 +4149,7 @@ function printIncidents(report) {
|
|
|
4014
4149
|
* open GET /api/status; exits 1 when the platform does not answer.
|
|
4015
4150
|
*/
|
|
4016
4151
|
async function platformStatusCmd(flags) {
|
|
4152
|
+
if (flags.history === true) return platformHistoryCmd(flags);
|
|
4017
4153
|
const url = remoteUrl(flags) ?? DEFAULT_PLATFORM;
|
|
4018
4154
|
const { report, error } = await fetchPlatformStatus(url, flags);
|
|
4019
4155
|
if (flags.json === true) {
|
|
@@ -4039,6 +4175,43 @@ async function platformStatusCmd(flags) {
|
|
|
4039
4175
|
return 0;
|
|
4040
4176
|
}
|
|
4041
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
|
+
|
|
4042
4215
|
async function statusCmd(layout, flags, only) {
|
|
4043
4216
|
if (flags.platform === true) return platformStatusCmd(flags);
|
|
4044
4217
|
const { isSourcePath, accountTreeFrom } = await core();
|
|
@@ -4171,7 +4344,21 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4171
4344
|
const [verb, name] = positional;
|
|
4172
4345
|
const url = flags.local === true ? undefined : remoteUrl(flags);
|
|
4173
4346
|
|
|
4174
|
-
if (verb === "new" || verb === "add")
|
|
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
|
+
}
|
|
4175
4362
|
|
|
4176
4363
|
if (verb === "rm" || verb === "remove" || verb === "delete") {
|
|
4177
4364
|
if (!name) throw new Error("which workspace? `foldrun workspaces rm <name>`");
|
|
@@ -4200,7 +4387,7 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4200
4387
|
return 0;
|
|
4201
4388
|
}
|
|
4202
4389
|
if (verb && verb !== "ls" && verb !== "list") {
|
|
4203
|
-
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`);
|
|
4204
4391
|
}
|
|
4205
4392
|
|
|
4206
4393
|
const here = layout.workspaces;
|
|
@@ -4214,6 +4401,23 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4214
4401
|
}
|
|
4215
4402
|
}
|
|
4216
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
|
+
}
|
|
4217
4421
|
if (!all.length) {
|
|
4218
4422
|
console.log(`\n ${c.dim("no workspaces — `foldrun new <name>` makes one")}\n`);
|
|
4219
4423
|
return 0;
|
|
@@ -4623,7 +4827,7 @@ async function triggersCmd(flags, layout) {
|
|
|
4623
4827
|
const ws = typeof flags.to === "string" ? flags.to : layout?.kind === "empty" ? null : takeWorkspace([], layout);
|
|
4624
4828
|
if (!ws) throw new Error("which workspace? `foldrun triggers --to <workspace>` — or run it inside one");
|
|
4625
4829
|
const days = Number(flags.since) > 0 ? Math.floor(Number(flags.since)) : 7;
|
|
4626
|
-
const body = await remoteCall(url, flags, `/api/workspaces/${ws}/triggers?
|
|
4830
|
+
const body = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/triggers?days=${days}`);
|
|
4627
4831
|
const rows = body.flows ?? [];
|
|
4628
4832
|
if (!rows.length) {
|
|
4629
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`);
|
|
@@ -4730,6 +4934,65 @@ async function providersCmd(url, flags) {
|
|
|
4730
4934
|
return broken ? 1 : 0;
|
|
4731
4935
|
}
|
|
4732
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
|
+
|
|
4733
4996
|
// --------------------------------------------------------------- billing
|
|
4734
4997
|
|
|
4735
4998
|
/**
|
|
@@ -4827,9 +5090,10 @@ async function accountCmd(positional, flags) {
|
|
|
4827
5090
|
return 0;
|
|
4828
5091
|
}
|
|
4829
5092
|
if (verb === "providers") return providersCmd(url, flags);
|
|
5093
|
+
if (verb === "model") return accountModelCmd(url, positional.slice(1), flags);
|
|
4830
5094
|
if (verb === "export") return accountExportCmd(url, flags);
|
|
4831
5095
|
if (verb !== "set" && verb !== "clear") {
|
|
4832
|
-
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`);
|
|
4833
5097
|
}
|
|
4834
5098
|
|
|
4835
5099
|
const key = positional[1];
|
|
@@ -5140,6 +5404,12 @@ const waitingSteps = (run) =>
|
|
|
5140
5404
|
.map((s, i) => ({ step: s, index: i }))
|
|
5141
5405
|
.filter(({ step }) => step.status === "awaiting-approval" && step.waitFor !== "event");
|
|
5142
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
|
+
|
|
5143
5413
|
// ------------------------------------------------------------- approvals
|
|
5144
5414
|
|
|
5145
5415
|
/**
|
|
@@ -5232,17 +5502,23 @@ async function decideCmd(decision, runId, flags, layout) {
|
|
|
5232
5502
|
}
|
|
5233
5503
|
const ws = await workspaceOfRun(url, flags, layout, runId);
|
|
5234
5504
|
const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
|
|
5235
|
-
|
|
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);
|
|
5236
5510
|
if (!waiting.length) {
|
|
5237
5511
|
throw new Error(
|
|
5238
|
-
|
|
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`,
|
|
5239
5515
|
);
|
|
5240
5516
|
}
|
|
5241
5517
|
|
|
5242
5518
|
const only = flags.step === undefined ? null : Number(flags.step) - 1;
|
|
5243
5519
|
if (only !== null && !waiting.some(({ index }) => index === only)) {
|
|
5244
5520
|
throw new Error(
|
|
5245
|
-
`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"}`,
|
|
5246
5522
|
);
|
|
5247
5523
|
}
|
|
5248
5524
|
const chosen = only === null ? waiting : waiting.filter(({ index }) => index === only);
|
|
@@ -5267,7 +5543,12 @@ async function decideCmd(decision, runId, flags, layout) {
|
|
|
5267
5543
|
return 1;
|
|
5268
5544
|
}
|
|
5269
5545
|
|
|
5270
|
-
const body = {
|
|
5546
|
+
const body = {
|
|
5547
|
+
decision,
|
|
5548
|
+
...(note ? { note, ...(decision === "reject" ? { reason: note } : {}) } : {}),
|
|
5549
|
+
...(only === null ? {} : { step: only }),
|
|
5550
|
+
...(payload === undefined ? {} : { payload }),
|
|
5551
|
+
};
|
|
5271
5552
|
const answer = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/approve`, {
|
|
5272
5553
|
method: "POST",
|
|
5273
5554
|
body: JSON.stringify(body),
|
|
@@ -5280,6 +5561,23 @@ async function decideCmd(decision, runId, flags, layout) {
|
|
|
5280
5561
|
return 0;
|
|
5281
5562
|
}
|
|
5282
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
|
+
|
|
5283
5581
|
// ------------------------------------------------------------------ stop
|
|
5284
5582
|
|
|
5285
5583
|
/**
|
|
@@ -6347,6 +6645,106 @@ function hasDescription(raw) {
|
|
|
6347
6645
|
return /^\s+\S/.test(lines[at + 1] ?? "");
|
|
6348
6646
|
}
|
|
6349
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
|
+
|
|
6350
6748
|
/**
|
|
6351
6749
|
* `foldrun agent link <agent> --subagent <worker> | --consult <agent> |
|
|
6352
6750
|
* --can-ask`, and `unlink` — an agent's team, edited in its frontmatter
|
|
@@ -6540,6 +6938,27 @@ async function flowShowCmd(positional, flags, layout) {
|
|
|
6540
6938
|
return errors ? 1 : 0;
|
|
6541
6939
|
}
|
|
6542
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
|
+
|
|
6543
6962
|
/**
|
|
6544
6963
|
* `foldrun flow rotate-hook <flow>` — a new webhook URL for one flow. The
|
|
6545
6964
|
* old one stops working the moment this returns, so it asks first.
|
|
@@ -6632,9 +7051,20 @@ function bulkFilter(flags) {
|
|
|
6632
7051
|
if (typeof flags.status === "string") body.status = flags.status.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6633
7052
|
if (typeof flags.flow === "string") body.flow = flags.flow;
|
|
6634
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);
|
|
6635
7055
|
return body;
|
|
6636
7056
|
}
|
|
6637
|
-
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
|
+
}
|
|
6638
7068
|
|
|
6639
7069
|
/**
|
|
6640
7070
|
* `foldrun stop|rerun --status … --since … --flow …` — one decision, many
|
|
@@ -6771,6 +7201,100 @@ async function remoteBytes(url, flags, apiPath, what) {
|
|
|
6771
7201
|
return { bytes: Buffer.from(await res.arrayBuffer()), name };
|
|
6772
7202
|
}
|
|
6773
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
|
+
|
|
6774
7298
|
/**
|
|
6775
7299
|
* `foldrun report <run-id> get <agent>/<path>` — one file a run archived:
|
|
6776
7300
|
* whatever an agent left in outputs/, copied under the run when it ended.
|
|
@@ -6833,7 +7357,7 @@ async function observeCmd(flags, layout) {
|
|
|
6833
7357
|
const obs = (
|
|
6834
7358
|
await pool(names, 8, async (ws) => {
|
|
6835
7359
|
try {
|
|
6836
|
-
return await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/observe?
|
|
7360
|
+
return await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/observe?days=${since}`);
|
|
6837
7361
|
} catch (err) {
|
|
6838
7362
|
if (typeof flags.to === "string") throw err;
|
|
6839
7363
|
unreachable.push([ws, explain(err)]);
|
|
@@ -7038,7 +7562,27 @@ async function billingVerbCmd(positional, flags) {
|
|
|
7038
7562
|
return 0;
|
|
7039
7563
|
}
|
|
7040
7564
|
|
|
7041
|
-
|
|
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)`);
|
|
7042
7586
|
}
|
|
7043
7587
|
|
|
7044
7588
|
/** The defaults a workspace's AGENTS.md sets, read from its source. */
|
|
@@ -7233,6 +7777,49 @@ async function notificationsCmd(positional, flags) {
|
|
|
7233
7777
|
return 0;
|
|
7234
7778
|
}
|
|
7235
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
|
+
|
|
7236
7823
|
/**
|
|
7237
7824
|
* `foldrun history [path] --to <workspace>` — every change to a deployed
|
|
7238
7825
|
* workspace, newest first: who, what, which files. `--id <revision>` shows
|
|
@@ -7405,7 +7992,27 @@ async function backupsCmd(positional, flags) {
|
|
|
7405
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`);
|
|
7406
7993
|
return 0;
|
|
7407
7994
|
}
|
|
7408
|
-
if (verb
|
|
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`);
|
|
7409
8016
|
const b = await remoteCall(url, flags, "/api/account/backups");
|
|
7410
8017
|
if (flags.json === true) {
|
|
7411
8018
|
console.log(JSON.stringify(b, null, 2));
|
|
@@ -7571,6 +8178,16 @@ async function flowEditCmd(flags, layout, { flow, what, label, compute, bodies }
|
|
|
7571
8178
|
return 0;
|
|
7572
8179
|
}
|
|
7573
8180
|
|
|
8181
|
+
/** Two parsed flows that run the same: equal but for key order and the
|
|
8182
|
+
* line numbers a reflow moves. */
|
|
8183
|
+
function sameFlow(a, b) {
|
|
8184
|
+
const canon = (v) =>
|
|
8185
|
+
Array.isArray(v) ? v.map(canon)
|
|
8186
|
+
: v && typeof v === "object" ? Object.fromEntries(Object.keys(v).filter((k) => k !== "line").sort().map((k) => [k, canon(v[k])]))
|
|
8187
|
+
: v;
|
|
8188
|
+
return JSON.stringify(canon(a)) === JSON.stringify(canon(b));
|
|
8189
|
+
}
|
|
8190
|
+
|
|
7574
8191
|
/** The step options the dashboard's step editor writes (core updateFlowStep). */
|
|
7575
8192
|
const STEP_OPTION_KEYS = ["model", "effort", "retry", "timeout", "verify", "when", "case", "else", "loop", "until", "each", "max", "limits"];
|
|
7576
8193
|
|
|
@@ -7586,6 +8203,11 @@ async function flowSetCmd(positional, flags, layout) {
|
|
|
7586
8203
|
const pairs = positional.slice(2);
|
|
7587
8204
|
const usage = `\`foldrun flow set <flow> --step <n|agent> key=value …\` — keys: ${STEP_OPTION_KEYS.join(", ")}, instruction (key= clears)`;
|
|
7588
8205
|
if (!flow || !pairs.length) throw new Error(usage);
|
|
8206
|
+
const { resolveEffort, EFFORT_LEVELS, parseDuration } = await core();
|
|
8207
|
+
// The counted options, in the ranges core's rewriter and parseFlow share.
|
|
8208
|
+
// Outside them the rewriter clamps, zeroes (retry=abc wrote 0) or drops
|
|
8209
|
+
// the line (timeout=15m deleted timeout: 900) and the edit "succeeded".
|
|
8210
|
+
const COUNTS = { retry: [0, 5, "extra attempts"], loop: [1, 5, "extra cycles"], max: [1, 20, "items"] };
|
|
7589
8211
|
const options = {};
|
|
7590
8212
|
let instruction;
|
|
7591
8213
|
for (const p of pairs) {
|
|
@@ -7603,9 +8225,18 @@ async function flowSetCmd(positional, flags, layout) {
|
|
|
7603
8225
|
// dropped without a word. Refuse it here and say where it lives.
|
|
7604
8226
|
if (key === "each" && value && value !== "lines") throw new Error(`each=${value}: flow set writes \`each: lines\` only — \`foldrun flow add <flow> fan-out --each "${value}"\` writes the rest`);
|
|
7605
8227
|
if (key === "else" && value && !["true", "1"].includes(value)) throw new Error("else=true, or else= to clear it");
|
|
8228
|
+
if (key === "effort" && value && !resolveEffort(value)) throw new Error(`effort=${value} is not an effort — ${EFFORT_LEVELS.join(", ")} (or effort= to clear it)`);
|
|
8229
|
+
if (key === "timeout" && value && !/^[1-9]\d*$/.test(value)) {
|
|
8230
|
+
const secs = parseDuration(value);
|
|
8231
|
+
throw new Error(`timeout=${value}: flow set writes a whole number of seconds — ${secs ? `timeout=${secs}` : "e.g. timeout=900 for 15 minutes"} (or timeout= to clear it)`);
|
|
8232
|
+
}
|
|
8233
|
+
if (key in COUNTS && value) {
|
|
8234
|
+
const [lo, hi, what] = COUNTS[key];
|
|
8235
|
+
if (!/^\d+$/.test(value) || Number(value) < lo || Number(value) > hi) throw new Error(`${key}=${value}: a whole number of ${what}, ${lo} to ${hi} (or ${key}= to clear it)`);
|
|
8236
|
+
}
|
|
7606
8237
|
options[key] = value === "" ? null : value;
|
|
7607
8238
|
}
|
|
7608
|
-
const { updateFlowStep, updateFlowStepInstruction } = await core();
|
|
8239
|
+
const { updateFlowStep, updateFlowStepInstruction, parseFlow } = await core();
|
|
7609
8240
|
return flowEditCmd(flags, layout, {
|
|
7610
8241
|
flow,
|
|
7611
8242
|
what: "flow set",
|
|
@@ -7617,6 +8248,10 @@ async function flowSetCmd(positional, flags, layout) {
|
|
|
7617
8248
|
const afterInstruction = instruction !== undefined ? updateFlowStepInstruction(next, i, instruction) : next;
|
|
7618
8249
|
next = afterInstruction;
|
|
7619
8250
|
if (Object.keys(options).length) next = updateFlowStep(next, i, options);
|
|
8251
|
+
// The rewriter re-emits the managed lines at the top of the step, so a
|
|
8252
|
+
// value the step already has still came back reordered — a diff, and on
|
|
8253
|
+
// the platform a revision, for no change. Same flow when parsed: none.
|
|
8254
|
+
if (next !== raw && sameFlow(parseFlow("flow.md", raw), parseFlow("flow.md", next))) next = raw;
|
|
7620
8255
|
return { next, meta: { i, afterInstruction, label: `step ${i + 1} · ${s.subflow ? `flow:${s.subflow}` : s.agent}` } };
|
|
7621
8256
|
},
|
|
7622
8257
|
bodies: (raw, m) => [
|
|
@@ -7784,8 +8419,9 @@ async function flowDraftCmd(positional, flags, layout) {
|
|
|
7784
8419
|
return 0;
|
|
7785
8420
|
}
|
|
7786
8421
|
if (flags.yes !== true && !process.stdin.isTTY) {
|
|
7787
|
-
|
|
7788
|
-
|
|
8422
|
+
// The model call was made and charged; a script must not read that as done.
|
|
8423
|
+
console.log(` ${c.dim(`the draft call was made (charged like a step), but this is not a terminal to ask — nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`} (the draft will differ), or save them with \`foldrun source put\``)}\n`);
|
|
8424
|
+
return 1;
|
|
7789
8425
|
}
|
|
7790
8426
|
if (!(await confirmed(flags, "flow draft", `Save ${files.length} file${files.length === 1 ? "" : "s"} to ${ws}${files.some((f) => f.before !== null) ? " (replacing what is there)" : ""}? [y/N] `))) {
|
|
7791
8427
|
console.log(`\n ${c.dim("nothing saved")}\n`);
|
|
@@ -7859,7 +8495,7 @@ async function scheduleTickCmd(flags) {
|
|
|
7859
8495
|
return 0;
|
|
7860
8496
|
}
|
|
7861
8497
|
|
|
7862
|
-
const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge"];
|
|
8498
|
+
const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge", "memory"];
|
|
7863
8499
|
|
|
7864
8500
|
/**
|
|
7865
8501
|
* `foldrun library [ls [kind]] | cat <kind>/<path> | put <kind>/<path> | rm
|
|
@@ -7886,7 +8522,7 @@ async function libraryCmd(positional, flags) {
|
|
|
7886
8522
|
console.log(` ${c.bold(kinds[n])} ${c.dim(`${entries.length}`)}`);
|
|
7887
8523
|
for (const e of entries) console.log(` ${pad(e.name ?? e.path ?? "", 28)} ${c.dim(firstLine(e.description ?? "", 70))}`);
|
|
7888
8524
|
});
|
|
7889
|
-
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`);
|
|
7890
8526
|
return 0;
|
|
7891
8527
|
}
|
|
7892
8528
|
if (verb === "cat") {
|
|
@@ -7905,6 +8541,17 @@ async function libraryCmd(positional, flags) {
|
|
|
7905
8541
|
console.log(`\n ${c.green("✓")} library/${kind}/${rel} ${c.dim(`${humanBytes(Buffer.byteLength(content))} — every workspace in the account sees it`)}\n`);
|
|
7906
8542
|
return 0;
|
|
7907
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
|
+
}
|
|
7908
8555
|
if (verb === "rm") {
|
|
7909
8556
|
const { kind, rel } = split(spec);
|
|
7910
8557
|
if (!(await sureToDelete(flags, "library rm", `library/${kind}/${rel} — every workspace that names it loses it`))) return 1;
|
|
@@ -7912,7 +8559,7 @@ async function libraryCmd(positional, flags) {
|
|
|
7912
8559
|
console.log(`\n ${c.green("✓")} removed library/${kind}/${rel}\n`);
|
|
7913
8560
|
return 0;
|
|
7914
8561
|
}
|
|
7915
|
-
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`);
|
|
7916
8563
|
}
|
|
7917
8564
|
|
|
7918
8565
|
/**
|
|
@@ -8241,6 +8888,95 @@ async function findCmd(positional, flags) {
|
|
|
8241
8888
|
}
|
|
8242
8889
|
|
|
8243
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
|
+
|
|
8244
8980
|
async function workspacesDemoCmd(flags) {
|
|
8245
8981
|
const url = platformFor(flags, "workspaces demo");
|
|
8246
8982
|
const r = await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ demo: true }) });
|
|
@@ -8447,10 +9183,15 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8447
9183
|
: check(workspace, flags);
|
|
8448
9184
|
case "pull":
|
|
8449
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);
|
|
8450
9190
|
case "status":
|
|
8451
9191
|
return statusCmd(layout, flags, positional[0]);
|
|
8452
9192
|
case "workspaces":
|
|
8453
9193
|
if (positional[0] === "demo") return workspacesDemoCmd(flags);
|
|
9194
|
+
if (positional[0] === "show") return workspacesShowCmd(positional[1], flags);
|
|
8454
9195
|
return workspacesCmd(positional, flags, layout);
|
|
8455
9196
|
case "extract":
|
|
8456
9197
|
return extract(workspace, flags);
|
|
@@ -8460,6 +9201,9 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8460
9201
|
needsOne(layout, "run");
|
|
8461
9202
|
return runTarget(positional[0], flags);
|
|
8462
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);
|
|
8463
9207
|
// --to names a deployed workspace: its evals run there.
|
|
8464
9208
|
if (typeof flags.to === "string") return remoteEvalsCmd(positional[0], flags, layout);
|
|
8465
9209
|
needsOne(layout, "eval");
|
|
@@ -8479,6 +9223,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8479
9223
|
return webhooksCmd(positional, flags, layout);
|
|
8480
9224
|
case "notifications":
|
|
8481
9225
|
return notificationsCmd(positional, flags);
|
|
9226
|
+
case "flags":
|
|
9227
|
+
return flagsCmd(positional, flags);
|
|
8482
9228
|
case "history":
|
|
8483
9229
|
if (positional[0] === "restore") return historyRestoreCmd(positional, flags, layout);
|
|
8484
9230
|
return historyCmd(positional, flags, layout);
|
|
@@ -8488,6 +9234,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8488
9234
|
return restoreCmd(positional, flags);
|
|
8489
9235
|
case "backups":
|
|
8490
9236
|
return backupsCmd(positional, flags);
|
|
9237
|
+
case "team":
|
|
9238
|
+
return teamCmd(positional, flags);
|
|
8491
9239
|
case "onboarding":
|
|
8492
9240
|
return onboardingCmd(flags);
|
|
8493
9241
|
case "probe":
|
|
@@ -8560,6 +9308,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8560
9308
|
// `new` scaffolds one here; `run` runs one there. The noun is the same
|
|
8561
9309
|
// thing in both cases, which is why they share a command.
|
|
8562
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);
|
|
8563
9313
|
return positional[0] === "run"
|
|
8564
9314
|
? agentRunCmd(positional, flags, layout)
|
|
8565
9315
|
: scaffoldCmd("agents", positional, flags, layout);
|
|
@@ -8572,12 +9322,14 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8572
9322
|
if (positional[0] === "show") return flowShowCmd(positional, flags, layout);
|
|
8573
9323
|
if (positional[0] === "run") return invoke(positional[1], flags);
|
|
8574
9324
|
if (positional[0] === "rotate-hook") return rotateHookCmd(positional, flags, layout);
|
|
9325
|
+
if (positional[0] === "hook") return flowHookCmd(positional, flags, layout);
|
|
8575
9326
|
if (positional[0] === "set") return flowSetCmd(positional, flags, layout);
|
|
8576
9327
|
if (positional[0] === "trigger") return flowTriggerCmd(positional, flags, layout);
|
|
8577
9328
|
if (positional[0] === "move-step") return flowMoveStepCmd(positional, flags, layout);
|
|
8578
9329
|
if (positional[0] === "copy-step") return flowCopyStepCmd(positional, flags, layout);
|
|
8579
9330
|
if (positional[0] === "paste") return flowPasteCmd(positional, flags, layout);
|
|
8580
9331
|
if (positional[0] === "draft") return flowDraftCmd(positional, flags, layout);
|
|
9332
|
+
if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("flows", flags, layout);
|
|
8581
9333
|
return scaffoldCmd("flows", positional, flags, layout);
|
|
8582
9334
|
case "tool":
|
|
8583
9335
|
return positional[0] === "test"
|