foldrun 0.8.0 → 1.0.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 +9 -6
- package/bin/foldrun.mjs +77 -30
- package/docs/agents.md +66 -5
- package/docs/api-usage.md +20 -5
- package/docs/api.md +33 -16
- package/docs/backups-and-restore.md +35 -66
- package/docs/budgets-and-billing.md +5 -1
- package/docs/cli.md +45 -17
- 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/hooks.md +139 -0
- package/docs/notifications.md +7 -1
- package/docs/providers.md +28 -0
- package/docs/runs.md +7 -2
- package/docs/security.md +14 -0
- package/docs/tools.md +46 -6
- package/docs/versions.md +64 -2
- package/docs/workspaces.md +87 -0
- package/package.json +4 -4
- package/src/commands.mjs +1206 -39
- package/src/guide.mjs +1 -1
- package/src/terminal.mjs +85 -0
package/src/commands.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// CLI commands. Kept separate from bin/ so the environment is set before the
|
|
2
2
|
// core is imported — single-workspace mode is read at module load.
|
|
3
3
|
|
|
4
|
+
import { writeExact } from "./terminal.mjs";
|
|
4
5
|
import dns from "node:dns";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import crypto from "node:crypto";
|
|
@@ -78,7 +79,7 @@ function shippedTemplates() {
|
|
|
78
79
|
}
|
|
79
80
|
|
|
80
81
|
/** The files of one workspace: a template if asked for, else the starter. */
|
|
81
|
-
async function workspaceFiles(name, from) {
|
|
82
|
+
async function workspaceFiles(name, from, starter = true) {
|
|
82
83
|
const { starterFiles } = await core();
|
|
83
84
|
// A template is a source, a workspace is a destination. Keeping the two
|
|
84
85
|
// words apart is the whole reason `templates/` is not called `examples/`:
|
|
@@ -91,7 +92,15 @@ async function workspaceFiles(name, from) {
|
|
|
91
92
|
if (from && !source) {
|
|
92
93
|
throw new Error(`no template at ${from} — pass a directory, or one that ships with foldrun: ${shippedTemplates().join(", ") || "none found"}`);
|
|
93
94
|
}
|
|
94
|
-
|
|
95
|
+
// Blank unless asked: the example researcher and writer met people as
|
|
96
|
+
// clutter in a workspace made for real work (owner, 2026-10-05). --starter
|
|
97
|
+
// brings them back; --from copies a template.
|
|
98
|
+
const { blankWorkspaceFiles } = /** @type {any} */ (await core());
|
|
99
|
+
const files = source
|
|
100
|
+
? templateFilesFrom(source)
|
|
101
|
+
: starter || !blankWorkspaceFiles
|
|
102
|
+
? starterFiles(name)
|
|
103
|
+
: blankWorkspaceFiles(name);
|
|
95
104
|
|
|
96
105
|
// Whatever the source, the new workspace must ignore the key that decrypts
|
|
97
106
|
// its secrets. A template does not carry one — it is a source, not a
|
|
@@ -206,7 +215,11 @@ function report(where, written, outside, wsDir, files) {
|
|
|
206
215
|
console.log(`
|
|
207
216
|
${c.bold("Next")}
|
|
208
217
|
foldrun check ${rel}${" ".repeat(Math.max(1, 16 - rel.length))}${c.dim("validate it — costs nothing")}
|
|
209
|
-
|
|
218
|
+
${
|
|
219
|
+
flow
|
|
220
|
+
? `foldrun run ${flow} --workspace ${path.relative(process.cwd(), wsDir) || "."} ${c.dim("run the flow")}`
|
|
221
|
+
: `foldrun agent import <workspace>/<agent> ${c.dim("bring in an agent you already have — or `foldrun agent new <name>`")}`
|
|
222
|
+
}
|
|
210
223
|
`);
|
|
211
224
|
}
|
|
212
225
|
|
|
@@ -231,8 +244,11 @@ async function newWorkspace(name, flags, layout) {
|
|
|
231
244
|
}
|
|
232
245
|
const dir = path.join(layout.workspacesDir, name);
|
|
233
246
|
if (fs.existsSync(dir)) throw new Error(`${dir} already exists`);
|
|
234
|
-
const files = await workspaceFiles(name, flags.from);
|
|
247
|
+
const files = await workspaceFiles(name, flags.from, flags.starter === true);
|
|
235
248
|
writeFiles(dir, files);
|
|
249
|
+
// A workspace is a folder holding agents/ or flows/; a blank one has
|
|
250
|
+
// neither file yet, so the folders are made for it to be found as one.
|
|
251
|
+
for (const sub of ["agents", "flows"]) fs.mkdirSync(path.join(dir, sub), { recursive: true });
|
|
236
252
|
syncWorkspaceBundles(dir);
|
|
237
253
|
report(dir, files.map((f) => f.path), [], dir, files);
|
|
238
254
|
return 0;
|
|
@@ -252,7 +268,7 @@ function workspaceDirFor(flags, layout) {
|
|
|
252
268
|
if (typeof flags.to === "string") {
|
|
253
269
|
if (!layout.workspacesDir || !layout.workspaces.includes(flags.to)) {
|
|
254
270
|
throw new Error(
|
|
255
|
-
`no workspace called "${flags.to}" here — this ${layout.kind === "flat" ? "is a single workspace" : `account has ${layout.workspaces.join(", ") || "none"}`}`,
|
|
271
|
+
`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
272
|
);
|
|
257
273
|
}
|
|
258
274
|
return path.join(layout.workspacesDir, flags.to);
|
|
@@ -328,7 +344,7 @@ async function scaffoldCmd(kind, positional, flags, layout) {
|
|
|
328
344
|
|
|
329
345
|
// The other verbs are dispatched before this — `agent run` and `tool test`
|
|
330
346
|
// 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] ?? [];
|
|
347
|
+
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
348
|
const verb = positional[0];
|
|
333
349
|
if (verb !== "new") {
|
|
334
350
|
const verbs = ["new", ...alsoVerbs].join(", ");
|
|
@@ -341,6 +357,10 @@ async function scaffoldCmd(kind, positional, flags, layout) {
|
|
|
341
357
|
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(name)) {
|
|
342
358
|
throw new Error(`"${name}" is not a ${one} name — kebab-case only, e.g. ${KINDS[kind].placeholder}`);
|
|
343
359
|
}
|
|
360
|
+
// --platform: the dashboard's New button, on the deployed workspace —
|
|
361
|
+
// nothing is written here. `--to` alone still names a workspace of this
|
|
362
|
+
// folder, as it always has.
|
|
363
|
+
if (flags.platform === true) return platformScaffold(kind, name, flags, layout);
|
|
344
364
|
|
|
345
365
|
const dir = workspaceDirFor(flags, layout);
|
|
346
366
|
let files;
|
|
@@ -375,6 +395,74 @@ async function scaffoldCmd(kind, positional, flags, layout) {
|
|
|
375
395
|
return 0;
|
|
376
396
|
}
|
|
377
397
|
|
|
398
|
+
/**
|
|
399
|
+
* `foldrun agent new <name> --platform`, `flow new … --platform`, `tool new
|
|
400
|
+
* … --platform` — the dashboard's New button on a deployed workspace: the
|
|
401
|
+
* platform writes its own template (POST …/agents, …/flows, …/assets), so
|
|
402
|
+
* what lands is what the canvas would have made, as a revision.
|
|
403
|
+
*/
|
|
404
|
+
async function platformScaffold(kind, name, flags, layout) {
|
|
405
|
+
const one = { agents: "agent", flows: "flow", tools: "tool" }[kind];
|
|
406
|
+
const url = platformFor(flags, `${one} new --platform`);
|
|
407
|
+
const ws = platformWorkspace(flags, layout, `${one} new ${name} --platform`);
|
|
408
|
+
let route = `/api/workspaces/${enc(ws)}/${kind}`;
|
|
409
|
+
let body;
|
|
410
|
+
if (kind === "agents") {
|
|
411
|
+
body = { name, ...(typeof flags.description === "string" ? { description: flags.description } : {}), ...(typeof flags.model === "string" ? { model: flags.model } : {}) };
|
|
412
|
+
} else if (kind === "flows") {
|
|
413
|
+
if (flags.pattern !== undefined && !NEW_FLOW_SHAPES.includes(flags.pattern)) {
|
|
414
|
+
throw new Error(`--pattern takes ${NEW_FLOW_SHAPES.join(", ")} — not "${flags.pattern}"`);
|
|
415
|
+
}
|
|
416
|
+
body = { name, ...(typeof flags.pattern === "string" ? { pattern: flags.pattern } : {}) };
|
|
417
|
+
} else {
|
|
418
|
+
// A script tool, as `tool new` makes here — the platform's own default
|
|
419
|
+
// with no template is an http one.
|
|
420
|
+
route = `/api/workspaces/${enc(ws)}/assets`;
|
|
421
|
+
const template = typeof flags.template === "string" ? flags.template : typeof flags.transport === "string" ? flags.transport : "script";
|
|
422
|
+
body = { kind: "tools", name, template, ...(typeof flags.language === "string" ? { language: flags.language } : {}) };
|
|
423
|
+
}
|
|
424
|
+
const r = await remoteCall(url, flags, route, { method: "POST", body: JSON.stringify(body) });
|
|
425
|
+
console.log(`\n ${c.green("created")} ${one} ${c.bold(name)} in ${ws} ${c.dim(`on ${url}`)}`);
|
|
426
|
+
console.log(` ${c.dim(r.path ?? "")}${r.pattern ? c.dim(` · ${r.pattern}`) : ""}`);
|
|
427
|
+
console.log(`\n ${c.dim(`foldrun source cat ${r.path ?? "<path>"} --to ${ws} · foldrun check --to ${ws} · foldrun pull ${ws} brings it here`)}\n`);
|
|
428
|
+
return 0;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** The shapes the platform's New flow offers (core's store FLOW_PATTERNS). */
|
|
432
|
+
const NEW_FLOW_SHAPES = ["pipeline", "review-loop", "fan-out", "debate", "router"];
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* `foldrun agent ls` / `flow ls` — what is deployed in a workspace (GET
|
|
436
|
+
* …/agents, …/flows): the list the dashboard draws, from a terminal.
|
|
437
|
+
*/
|
|
438
|
+
async function remoteListCmd(kind, flags, layout) {
|
|
439
|
+
const one = kind === "agents" ? "agent" : "flow";
|
|
440
|
+
const url = platformFor(flags, `${one} ls`);
|
|
441
|
+
const ws = platformWorkspace(flags, layout, `${one} ls`);
|
|
442
|
+
const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/${kind}`);
|
|
443
|
+
const list = r[kind] ?? [];
|
|
444
|
+
if (flags.json === true) {
|
|
445
|
+
console.log(JSON.stringify(list, null, 2));
|
|
446
|
+
return 0;
|
|
447
|
+
}
|
|
448
|
+
if (!list.length) {
|
|
449
|
+
console.log(`\n ${c.dim(`no ${kind} in ${ws} on ${url} — \`foldrun ${one} new <name> --platform --to ${ws}\` makes one`)}\n`);
|
|
450
|
+
return 0;
|
|
451
|
+
}
|
|
452
|
+
const w = Math.max(...list.map((x) => x.name.length));
|
|
453
|
+
console.log();
|
|
454
|
+
for (const x of list) {
|
|
455
|
+
if (kind === "agents") {
|
|
456
|
+
console.log(` ${c.bold(pad(x.name, w))} ${pad(x.model ?? "", 8)} ${c.dim(firstLine(x.description ?? "", 80))}`);
|
|
457
|
+
} else {
|
|
458
|
+
const when = x.trigger === "schedule" && x.schedule ? `schedule ${x.schedule}${x.timezone ? ` ${x.timezone}` : ""}` : (x.trigger ?? "manual");
|
|
459
|
+
console.log(` ${c.bold(pad(x.name, w))} ${c.dim(when)}`);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
console.log(`\n ${c.dim(`${list.length} ${list.length === 1 ? one : kind} · ${ws} · ${url}`)}\n`);
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
|
|
378
466
|
// ---------------------------------------------------------------- check
|
|
379
467
|
|
|
380
468
|
// The Agent Skills spec constrains the `name` field and requires a non-empty
|
|
@@ -623,8 +711,10 @@ async function platformLibrary(flags) {
|
|
|
623
711
|
return new Set((body.entries ?? []).map((e) => e.name).filter(Boolean));
|
|
624
712
|
};
|
|
625
713
|
try {
|
|
626
|
-
|
|
627
|
-
|
|
714
|
+
// Hooks are newer than the library route on some platforms: a platform
|
|
715
|
+
// that does not list them leaves the set empty rather than failing check.
|
|
716
|
+
const [tools, skills, hooks] = await Promise.all([names("tools"), names("skills"), names("hooks").catch(() => new Set())]);
|
|
717
|
+
return { url, tools, skills, hooks, warning: null };
|
|
628
718
|
} catch (err) {
|
|
629
719
|
return { url, tools: new Set(), skills: new Set(), warning: `could not read the library on ${url} (${err instanceof Error ? err.message : err}) — tools defined there will be reported missing` };
|
|
630
720
|
}
|
|
@@ -685,6 +775,11 @@ async function checkAccount(layout, flags) {
|
|
|
685
775
|
const problems = [];
|
|
686
776
|
const note = (level, where, message) => problems.push({ level, where, message });
|
|
687
777
|
validateSkills(path.join(layout.accountRoot, "library"), note);
|
|
778
|
+
const hc = await hooksCore();
|
|
779
|
+
for (const p of hc ? hc.checkHooksDir(path.join(layout.accountRoot, "library", "hooks"), "account") : []) {
|
|
780
|
+
const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
|
|
781
|
+
note("error", m ? m[1] : "hooks/", m ? m[2] : p);
|
|
782
|
+
}
|
|
688
783
|
const counts = LIBRARY_KINDS.map((k) => ({ kind: k, n: files.filter((f) => f.kind === k).length })).filter((x) => x.n > 0);
|
|
689
784
|
console.log(`\n ${c.bold("library")} ${c.dim(counts.length ? counts.map((x) => `${x.n} ${x.kind}`).join(" · ") : "empty")}`);
|
|
690
785
|
for (const p of problems) {
|
|
@@ -777,7 +872,10 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
777
872
|
}
|
|
778
873
|
for (const s of platform.skills) skillNames.add(s);
|
|
779
874
|
|
|
780
|
-
|
|
875
|
+
// A blank workspace is legal — `foldrun new` and the dashboard make one —
|
|
876
|
+
// so this is a nudge, as the deploy's warning is; a flow naming a missing
|
|
877
|
+
// agent is still an error below.
|
|
878
|
+
if (agentNames.size === 0) note("warn", "agents/", "no agents yet — `foldrun agent new <name>`, or `foldrun agent import <workspace>/<agent>`");
|
|
781
879
|
|
|
782
880
|
// What format does this workspace target?
|
|
783
881
|
const agentsMd = path.join(workspace, "AGENTS.md");
|
|
@@ -793,7 +891,13 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
793
891
|
for (const w of readLimits(readAgentsMd(workspace)?.data?.limits).problems) note("error", "AGENTS.md", w);
|
|
794
892
|
}
|
|
795
893
|
|
|
894
|
+
const { unopenedFrontmatter } = /** @type {any} */ (await core());
|
|
796
895
|
for (const a of agents) {
|
|
896
|
+
// A frontmatter block with no opening `---` is read as prose: no tools,
|
|
897
|
+
// the default model (seo-digest's editor, 20 Sep to 5 Oct 2026).
|
|
898
|
+
const agentFile = path.join(workspace, "agents", a.name, "agent.md");
|
|
899
|
+
const unopened = unopenedFrontmatter && fs.existsSync(agentFile) ? unopenedFrontmatter(fs.readFileSync(agentFile, "utf8")) : null;
|
|
900
|
+
if (unopened) note("error", `agents/${a.name}/agent.md`, unopened, 1);
|
|
797
901
|
if (!a.description) note("warn", `agents/${a.name}`, "no description — other agents and people read it");
|
|
798
902
|
|
|
799
903
|
// A timezone nobody can read. The deploy already refuses it; check has to
|
|
@@ -957,6 +1061,8 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
957
1061
|
}
|
|
958
1062
|
}
|
|
959
1063
|
|
|
1064
|
+
await checkHooks(workspace, agents, libraryDir(T), platform, note);
|
|
1065
|
+
|
|
960
1066
|
// A script tool whose `run:` resolves to nothing parses, counts, and is
|
|
961
1067
|
// offered to the agent — then fails inside a turn. Checking it here is the
|
|
962
1068
|
// difference between a typo found in CI and a flow that quietly stops
|
|
@@ -1066,6 +1172,68 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
1066
1172
|
return { problems, summary };
|
|
1067
1173
|
}
|
|
1068
1174
|
|
|
1175
|
+
/**
|
|
1176
|
+
* Hooks, checked with core's own reader so check and a run agree: a hook
|
|
1177
|
+
* folder that is not a hook, a `hooks:` name that resolves to nothing (a run
|
|
1178
|
+
* refuses that step rather than run it unguarded), and a tool.call hook on an
|
|
1179
|
+
* agent whose web search or fetch the model provider runs on its own servers
|
|
1180
|
+
* — those calls never pass foldrun's dispatch, so the hook never sees them.
|
|
1181
|
+
* A core from before hooks has no reader, and then there is nothing to say.
|
|
1182
|
+
*/
|
|
1183
|
+
async function checkHooks(workspace, agents, libraryRoot, platform, note) {
|
|
1184
|
+
const h = await hooksCore();
|
|
1185
|
+
if (!h) return;
|
|
1186
|
+
for (const p of h.checkHooksDir(path.join(workspace, "hooks"), "workspace")) {
|
|
1187
|
+
const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
|
|
1188
|
+
note("error", m ? m[1] : "hooks/", m ? m[2] : p);
|
|
1189
|
+
}
|
|
1190
|
+
const matter = (await import("gray-matter")).default;
|
|
1191
|
+
const front = (file) => {
|
|
1192
|
+
try {
|
|
1193
|
+
return fs.existsSync(file) ? matter(fs.readFileSync(file, "utf8")).data ?? {} : {};
|
|
1194
|
+
} catch {
|
|
1195
|
+
return {}; // an unreadable file is reported by its own check
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
const exists = (n) =>
|
|
1199
|
+
fs.existsSync(path.join(workspace, "hooks", n)) || fs.existsSync(path.join(libraryRoot, "hooks", n)) || platform.hooks?.has(n);
|
|
1200
|
+
const missing = (where, names) => {
|
|
1201
|
+
for (const n of names) {
|
|
1202
|
+
if (!exists(n)) note("error", where, `hooks: [${n}] — no hooks/${n}/hook.md in this workspace or the account library`);
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
const accountNames = h.hookNames(front(path.join(path.dirname(libraryRoot), "AGENTS.md")).hooks);
|
|
1206
|
+
const workspaceNames = h.hookNames(front(path.join(workspace, "AGENTS.md")).hooks);
|
|
1207
|
+
missing("account AGENTS.md", accountNames);
|
|
1208
|
+
missing("AGENTS.md", workspaceNames);
|
|
1209
|
+
|
|
1210
|
+
let resolveSearch = null;
|
|
1211
|
+
try {
|
|
1212
|
+
({ resolveSearch } = /** @type {any} */ (await import("@foldrun/core/providers")));
|
|
1213
|
+
} catch {
|
|
1214
|
+
// no provider table: the provider-side search warning is skipped
|
|
1215
|
+
}
|
|
1216
|
+
for (const a of agents) {
|
|
1217
|
+
const f = front(path.join(workspace, "agents", a.name, "agent.md"));
|
|
1218
|
+
const own = h.hookNames(f.hooks);
|
|
1219
|
+
missing(`agents/${a.name}`, own);
|
|
1220
|
+
if (!resolveSearch || !f.web || typeof f.web !== "object" || Array.isArray(f.web)) continue;
|
|
1221
|
+
const { hooks } = h.resolveHooks({ workspaceRoot: workspace, libraryRoot, names: [...accountNames, ...workspaceNames, ...own] });
|
|
1222
|
+
const gates = hooks.filter((x) => x.on === "tool.call").map((x) => x.name);
|
|
1223
|
+
if (!gates.length) continue;
|
|
1224
|
+
for (const action of ["search", "fetch"]) {
|
|
1225
|
+
if (f.web[action] === undefined) continue;
|
|
1226
|
+
const r = resolveSearch(f.web[action], action);
|
|
1227
|
+
if (!r?.provider || r.error || r.shape === "direct") continue;
|
|
1228
|
+
note(
|
|
1229
|
+
"warn",
|
|
1230
|
+
`agents/${a.name}`,
|
|
1231
|
+
`web.${action}: ${r.provider} runs on the model provider's servers, so tool.call hook${gates.length === 1 ? "" : "s"} ${gates.join(", ")} never see${gates.length === 1 ? "s" : ""} those calls — leave web.${action}: unset (or name a ${action} API) for foldrun's own dispatch, which hooks guard`,
|
|
1232
|
+
);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1069
1237
|
/** The web tool the platform's gallery serves: built in, and the only
|
|
1070
1238
|
* built-in that takes a provider. */
|
|
1071
1239
|
const WEB_TOOL = "web";
|
|
@@ -1811,7 +1979,47 @@ export function semverNewer(a, b) {
|
|
|
1811
1979
|
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
|
|
1812
1980
|
if (x.pre && !y.pre) return false;
|
|
1813
1981
|
if (!x.pre && y.pre) return true;
|
|
1814
|
-
|
|
1982
|
+
if (!x.pre) return false;
|
|
1983
|
+
// SemVer 2.0.0 §11: dot-separated identifiers compared one by one — numbers
|
|
1984
|
+
// as numbers (rc.10 > rc.9), a number before a word, and a shorter list
|
|
1985
|
+
// before a longer one it begins.
|
|
1986
|
+
const xs = x.pre.slice(1).split(".");
|
|
1987
|
+
const ys = y.pre.slice(1).split(".");
|
|
1988
|
+
for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
|
|
1989
|
+
if (xs[i] == null || ys[i] == null) return ys[i] == null;
|
|
1990
|
+
const xn = /^\d+$/.test(xs[i]);
|
|
1991
|
+
const yn = /^\d+$/.test(ys[i]);
|
|
1992
|
+
if (xn && yn) {
|
|
1993
|
+
if (Number(xs[i]) !== Number(ys[i])) return Number(xs[i]) > Number(ys[i]);
|
|
1994
|
+
} else if (xn !== yn) return yn;
|
|
1995
|
+
else if (xs[i] !== ys[i]) return xs[i] > ys[i];
|
|
1996
|
+
}
|
|
1997
|
+
return false;
|
|
1998
|
+
}
|
|
1999
|
+
|
|
2000
|
+
/**
|
|
2001
|
+
* Is `latest` an update worth telling someone running `current` about? Newer,
|
|
2002
|
+
* and not a pre-release offered to someone on a stable release: npm keeps
|
|
2003
|
+
* pre-releases off `latest`, so `npm i -g foldrun@latest` would not even
|
|
2004
|
+
* install it.
|
|
2005
|
+
* @param {string} latest @param {string} current
|
|
2006
|
+
*/
|
|
2007
|
+
export function updateTo(latest, current) {
|
|
2008
|
+
// The whole header must be a version: it is printed, and a platform that
|
|
2009
|
+
// could append to it could write to the terminal.
|
|
2010
|
+
if (!/^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(latest ?? "").trim())) return false;
|
|
2011
|
+
const pre = (v) => /^v?\d+\.\d+\.\d+-/.test(String(v ?? "").trim());
|
|
2012
|
+
return semverNewer(latest, current) && !(pre(latest) && !pre(current));
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Text from a platform, made safe to print: no escape sequences or other
|
|
2017
|
+
* control characters, so a hostile or broken server cannot move the cursor,
|
|
2018
|
+
* retitle the terminal, write the clipboard (OSC 52) or disguise a link.
|
|
2019
|
+
* @param {unknown} s
|
|
2020
|
+
*/
|
|
2021
|
+
export function printable(s) {
|
|
2022
|
+
return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
1815
2023
|
}
|
|
1816
2024
|
|
|
1817
2025
|
const UPDATE_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -1831,14 +2039,14 @@ function updateNotice(res) {
|
|
|
1831
2039
|
if (env("FOLDRUN_NO_UPDATE_NOTICE")) return;
|
|
1832
2040
|
if (!(process.stderr.isTTY || env("FOLDRUN_FORCE_TTY") === "1")) return;
|
|
1833
2041
|
const latest = res.headers.get("x-foldrun-cli");
|
|
1834
|
-
if (!latest || !
|
|
2042
|
+
if (!latest || !updateTo(latest, CLI_VERSION)) return;
|
|
1835
2043
|
noticeDone = true;
|
|
1836
2044
|
const file = path.join(credentialsDir(), "version-check.json");
|
|
1837
2045
|
try {
|
|
1838
2046
|
const last = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1839
2047
|
if (Date.now() - Date.parse(last.notifiedAt) < UPDATE_EVERY_MS) return;
|
|
1840
2048
|
} catch {}
|
|
1841
|
-
const platform = res.headers.get("x-foldrun-version");
|
|
2049
|
+
const platform = printable(res.headers.get("x-foldrun-version") ?? "") || null;
|
|
1842
2050
|
console.error(` ${c.dim(`foldrun ${latest} is out (this is ${CLI_VERSION})${platform ? `, released with platform ${platform}` : ""} — npm i -g foldrun@latest`)}`);
|
|
1843
2051
|
fs.mkdirSync(credentialsDir(), { recursive: true });
|
|
1844
2052
|
fs.writeFileSync(file, JSON.stringify({ notifiedAt: new Date().toISOString(), cli: latest, platform }) + "\n");
|
|
@@ -1872,7 +2080,7 @@ async function versionCmd(flags) {
|
|
|
1872
2080
|
}
|
|
1873
2081
|
}
|
|
1874
2082
|
const latestCli = platform?.packages?.cli ?? null;
|
|
1875
|
-
const behind = latestCli ?
|
|
2083
|
+
const behind = latestCli ? updateTo(latestCli, CLI_VERSION) : false;
|
|
1876
2084
|
if (flags.json === true) {
|
|
1877
2085
|
console.log(JSON.stringify({ cli: CLI_VERSION, core, platform: platform ?? (url ? { url, error: unreachable } : null) }, null, 2));
|
|
1878
2086
|
if (behind) console.error(`foldrun ${latestCli} is out (this is ${CLI_VERSION}) — npm i -g foldrun@latest`);
|
|
@@ -3529,7 +3737,7 @@ async function auditCmd(positional, flags) {
|
|
|
3529
3737
|
q.set("format", "csv");
|
|
3530
3738
|
const { bytes, name } = await remoteBytes(url, flags, `/api/audit?${q}`, "the audit log");
|
|
3531
3739
|
if (typeof flags.file !== "string") {
|
|
3532
|
-
|
|
3740
|
+
writeExact(bytes);
|
|
3533
3741
|
return 0;
|
|
3534
3742
|
}
|
|
3535
3743
|
return writeDownload(bytes, name ?? "audit.csv", flags, "the audit log");
|
|
@@ -3605,7 +3813,7 @@ async function sourceCmd(positional, flags) {
|
|
|
3605
3813
|
if (verb === "cat" || verb === "read" || verb === "get") {
|
|
3606
3814
|
if (!a) throw new Error("which file? foldrun source cat flows/daily.md --to <workspace>");
|
|
3607
3815
|
const { content } = await remoteCall(url, flags, `${base}?path=${encodeURIComponent(a)}`);
|
|
3608
|
-
|
|
3816
|
+
writeExact(content.endsWith("\n") ? content : `${content}\n`);
|
|
3609
3817
|
return 0;
|
|
3610
3818
|
}
|
|
3611
3819
|
if (verb === "put" || verb === "write") {
|
|
@@ -3630,7 +3838,25 @@ async function sourceCmd(positional, flags) {
|
|
|
3630
3838
|
console.log(`\n ${c.green("✓")} removed ${a}\n`);
|
|
3631
3839
|
return 0;
|
|
3632
3840
|
}
|
|
3633
|
-
|
|
3841
|
+
if (verb === "new") {
|
|
3842
|
+
// The dashboard's New button for any document (POST …/assets): the
|
|
3843
|
+
// platform writes the template — a tool folder, a skill, knowledge,
|
|
3844
|
+
// memory, a script — at workspace scope, or inside one agent (--agent).
|
|
3845
|
+
const kinds = ["tools", "knowledge", "memory", "skills", "scripts", "agents", "flows", "evals"];
|
|
3846
|
+
if (!kinds.includes(a) || !b) throw new Error(`\`foldrun source new <kind> <name> --to <workspace>\` — kind is ${kinds.join(", ")} (--agent <name>, --template, --language)`);
|
|
3847
|
+
if (!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(b)) throw new Error(`"${b}" is not a name — kebab-case only`);
|
|
3848
|
+
const body = {
|
|
3849
|
+
kind: a,
|
|
3850
|
+
name: b,
|
|
3851
|
+
...(typeof flags.agent === "string" ? { agent: flags.agent } : {}),
|
|
3852
|
+
...(typeof flags.template === "string" ? { template: flags.template } : {}),
|
|
3853
|
+
...(typeof flags.language === "string" ? { language: flags.language } : {}),
|
|
3854
|
+
};
|
|
3855
|
+
const r = await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(ws)}/assets`, { method: "POST", body: JSON.stringify(body) });
|
|
3856
|
+
console.log(`\n ${c.green("✓")} ${ws}/${r.path ?? b} ${c.dim(`a new ${a.replace(/s$/, "")}${flags.agent ? ` for ${flags.agent}` : ""} · revision recorded`)}\n`);
|
|
3857
|
+
return 0;
|
|
3858
|
+
}
|
|
3859
|
+
throw new Error(`source: unknown verb "${verb}" — ls, cat, put, new, mv, rm`);
|
|
3634
3860
|
}
|
|
3635
3861
|
|
|
3636
3862
|
// ------------------------------------------------- the account, as a whole
|
|
@@ -3964,6 +4190,7 @@ async function reportRuntimes(url, workspace, flags, { waitSeconds = 0 } = {}) {
|
|
|
3964
4190
|
r.python && r.python !== true ? `python ${r.python}` : null,
|
|
3965
4191
|
...(r.packages ?? []),
|
|
3966
4192
|
...(r.npm ?? []).map((n) => `npm:${n}`),
|
|
4193
|
+
...(r.system ?? []).map((n) => `apt:${n}`),
|
|
3967
4194
|
].filter(Boolean).join(", ") || "python";
|
|
3968
4195
|
const who = c.dim(`(${(r.agents ?? []).join(", ")})`);
|
|
3969
4196
|
const mark =
|
|
@@ -4034,6 +4261,7 @@ function printIncidents(report) {
|
|
|
4034
4261
|
* open GET /api/status; exits 1 when the platform does not answer.
|
|
4035
4262
|
*/
|
|
4036
4263
|
async function platformStatusCmd(flags) {
|
|
4264
|
+
if (flags.history === true) return platformHistoryCmd(flags);
|
|
4037
4265
|
const url = remoteUrl(flags) ?? DEFAULT_PLATFORM;
|
|
4038
4266
|
const { report, error } = await fetchPlatformStatus(url, flags);
|
|
4039
4267
|
if (flags.json === true) {
|
|
@@ -4059,6 +4287,43 @@ async function platformStatusCmd(flags) {
|
|
|
4059
4287
|
return 0;
|
|
4060
4288
|
}
|
|
4061
4289
|
|
|
4290
|
+
/**
|
|
4291
|
+
* `foldrun status --platform --history [--days n]` — what the status page
|
|
4292
|
+
* draws as bars: each component's uptime per day over the window (1–90,
|
|
4293
|
+
* default 90), and the incidents in it. The open GET /api/status/history.
|
|
4294
|
+
*/
|
|
4295
|
+
async function platformHistoryCmd(flags) {
|
|
4296
|
+
const url = remoteUrl(flags) ?? DEFAULT_PLATFORM;
|
|
4297
|
+
const days = flags.days === undefined ? 90 : Number(flags.days);
|
|
4298
|
+
if (!Number.isInteger(days) || days < 1 || days > 90) throw new Error(`--days is a whole number from 1 to 90 — not "${flags.days}"`);
|
|
4299
|
+
const res = await remoteFetch(url, `/api/status/history?days=${days}`, {}, { seconds: timeoutSeconds(flags) });
|
|
4300
|
+
/** @type {any} */
|
|
4301
|
+
const body = await res.json().catch(() => ({}));
|
|
4302
|
+
if (!res.ok) throw new HttpError(body.error ?? `/api/status/history → HTTP ${res.status}`, res.status, body);
|
|
4303
|
+
if (flags.json === true) {
|
|
4304
|
+
console.log(JSON.stringify(body, null, 2));
|
|
4305
|
+
return 0;
|
|
4306
|
+
}
|
|
4307
|
+
const share = (u) => (u === null || u === undefined ? " — " : `${(u * 100).toFixed(u === 1 ? 0 : 2)}%`.padStart(6));
|
|
4308
|
+
const mark = (d) => (d.uptime === null ? c.dim("·") : d.outage ? c.red("▮") : d.degraded || d.maintenance ? c.amber("▮") : c.green("▮"));
|
|
4309
|
+
const comps = body.components ?? [];
|
|
4310
|
+
const wide = Math.max(0, ...comps.map((x) => x.name.length));
|
|
4311
|
+
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`);
|
|
4312
|
+
for (const comp of comps) console.log(` ${comp.name.padEnd(wide)} ${share(comp.uptime)} ${(comp.days ?? []).map(mark).join("")}`);
|
|
4313
|
+
const incidents = body.incidents ?? [];
|
|
4314
|
+
if (incidents.length) {
|
|
4315
|
+
console.log(`\n ${c.dim("incidents and maintenance")}`);
|
|
4316
|
+
for (const i of incidents) {
|
|
4317
|
+
const span = `${String(i.startsAt).slice(0, 16).replace("T", " ")}Z${i.resolvedAt ? ` → ${String(i.resolvedAt).slice(0, 16).replace("T", " ")}Z` : " · open"}`;
|
|
4318
|
+
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}`)}`);
|
|
4319
|
+
}
|
|
4320
|
+
} else {
|
|
4321
|
+
console.log(`\n ${c.dim("no incidents in the window")}`);
|
|
4322
|
+
}
|
|
4323
|
+
console.log(`\n ${c.dim("▮ a day: green up, amber degraded or maintenance, red an outage, · no samples · --days <n>, --json")}\n`);
|
|
4324
|
+
return 0;
|
|
4325
|
+
}
|
|
4326
|
+
|
|
4062
4327
|
async function statusCmd(layout, flags, only) {
|
|
4063
4328
|
if (flags.platform === true) return platformStatusCmd(flags);
|
|
4064
4329
|
const { isSourcePath, accountTreeFrom } = await core();
|
|
@@ -4191,7 +4456,21 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4191
4456
|
const [verb, name] = positional;
|
|
4192
4457
|
const url = flags.local === true ? undefined : remoteUrl(flags);
|
|
4193
4458
|
|
|
4194
|
-
if (verb === "new" || verb === "add")
|
|
4459
|
+
if (verb === "new" || verb === "add") {
|
|
4460
|
+
if (flags.platform !== true) return newWorkspace(name, flags, layout);
|
|
4461
|
+
// The dashboard's Create workspace: blank (an AGENTS.md), or with
|
|
4462
|
+
// --starter the example researcher, writer and publish flow.
|
|
4463
|
+
if (!url) throw new Error("--platform needs a platform — pass --url, or `foldrun login` first");
|
|
4464
|
+
if (!name) throw new Error("which workspace? `foldrun workspaces new <name> --platform`");
|
|
4465
|
+
assertName(name);
|
|
4466
|
+
const r = await remoteCall(url, flags, "/api/workspaces", {
|
|
4467
|
+
method: "POST",
|
|
4468
|
+
body: JSON.stringify(flags.starter === true ? { name, starter: true } : { name, template: true }),
|
|
4469
|
+
});
|
|
4470
|
+
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"}`)}`);
|
|
4471
|
+
console.log(` ${c.dim(`foldrun agent new <name> --platform --to ${r.name ?? name} · foldrun pull ${r.name ?? name}`)}\n`);
|
|
4472
|
+
return 0;
|
|
4473
|
+
}
|
|
4195
4474
|
|
|
4196
4475
|
if (verb === "rm" || verb === "remove" || verb === "delete") {
|
|
4197
4476
|
if (!name) throw new Error("which workspace? `foldrun workspaces rm <name>`");
|
|
@@ -4220,7 +4499,7 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4220
4499
|
return 0;
|
|
4221
4500
|
}
|
|
4222
4501
|
if (verb && verb !== "ls" && verb !== "list") {
|
|
4223
|
-
throw new Error(`workspaces: unknown verb "${verb}" — ls, new <name>, rm <name
|
|
4502
|
+
throw new Error(`workspaces: unknown verb "${verb}" — ls, show <name>, new <name>, rm <name>, demo`);
|
|
4224
4503
|
}
|
|
4225
4504
|
|
|
4226
4505
|
const here = layout.workspaces;
|
|
@@ -4234,6 +4513,23 @@ async function workspacesCmd(positional, flags, layout) {
|
|
|
4234
4513
|
}
|
|
4235
4514
|
}
|
|
4236
4515
|
const all = [...new Set([...here, ...there])].sort();
|
|
4516
|
+
// --json: for a script — `foldrun workspaces --json | jq -r '.workspaces[].name'`.
|
|
4517
|
+
// The table's footer once read as a workspace called "is" to a loop that
|
|
4518
|
+
// parsed it. Unreachable platform: still the list, with `error`, exit 1.
|
|
4519
|
+
if (flags.json === true) {
|
|
4520
|
+
console.log(
|
|
4521
|
+
JSON.stringify(
|
|
4522
|
+
{
|
|
4523
|
+
platform: url ?? null,
|
|
4524
|
+
workspaces: all.map((n) => ({ name: n, here: here.includes(n), platform: there.includes(n) })),
|
|
4525
|
+
...(reach ? { error: reach } : {}),
|
|
4526
|
+
},
|
|
4527
|
+
null,
|
|
4528
|
+
2,
|
|
4529
|
+
),
|
|
4530
|
+
);
|
|
4531
|
+
return reach ? 1 : 0;
|
|
4532
|
+
}
|
|
4237
4533
|
if (!all.length) {
|
|
4238
4534
|
console.log(`\n ${c.dim("no workspaces — `foldrun new <name>` makes one")}\n`);
|
|
4239
4535
|
return 0;
|
|
@@ -4522,7 +4818,7 @@ async function storageCmd(positional, flags, layout) {
|
|
|
4522
4818
|
if (bytes.includes(0)) {
|
|
4523
4819
|
throw new Error(`${rel} is not text (${humanBytes(bytes.length)}) — \`foldrun storage cat ${rel} --preview\` reads what is in it, \`foldrun storage get ${rel}\` writes it to a file`);
|
|
4524
4820
|
}
|
|
4525
|
-
|
|
4821
|
+
writeExact(bytes);
|
|
4526
4822
|
return 0;
|
|
4527
4823
|
}
|
|
4528
4824
|
|
|
@@ -4643,7 +4939,7 @@ async function triggersCmd(flags, layout) {
|
|
|
4643
4939
|
const ws = typeof flags.to === "string" ? flags.to : layout?.kind === "empty" ? null : takeWorkspace([], layout);
|
|
4644
4940
|
if (!ws) throw new Error("which workspace? `foldrun triggers --to <workspace>` — or run it inside one");
|
|
4645
4941
|
const days = Number(flags.since) > 0 ? Math.floor(Number(flags.since)) : 7;
|
|
4646
|
-
const body = await remoteCall(url, flags, `/api/workspaces/${ws}/triggers?
|
|
4942
|
+
const body = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/triggers?days=${days}`);
|
|
4647
4943
|
const rows = body.flows ?? [];
|
|
4648
4944
|
if (!rows.length) {
|
|
4649
4945
|
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 +5046,65 @@ async function providersCmd(url, flags) {
|
|
|
4750
5046
|
return broken ? 1 : 0;
|
|
4751
5047
|
}
|
|
4752
5048
|
|
|
5049
|
+
/**
|
|
5050
|
+
* `foldrun account model` — the account's own model key: what every step
|
|
5051
|
+
* runs on unless a provider: block names another (/api/account/model).
|
|
5052
|
+
*
|
|
5053
|
+
* foldrun account model what is set, what runs
|
|
5054
|
+
* foldrun account model set <provider> --key <api key> a preset (anthropic,
|
|
5055
|
+
* openrouter, openai, groq, …) or `custom --base-url https://… --format openai`
|
|
5056
|
+
* foldrun account model remove
|
|
5057
|
+
*
|
|
5058
|
+
* `--key -` reads the key from stdin, so it stays out of the shell's history.
|
|
5059
|
+
* The platform refuses a Claude login token for any account not allowed its
|
|
5060
|
+
* own credential: bring an API key.
|
|
5061
|
+
*/
|
|
5062
|
+
async function accountModelCmd(url, positional, flags) {
|
|
5063
|
+
const verb = positional[0];
|
|
5064
|
+
const show = (r) => {
|
|
5065
|
+
const runs =
|
|
5066
|
+
r.runsOn === "account"
|
|
5067
|
+
? `${c.green("✓")} your own key — ${c.bold(r.provider ?? "custom")} ${c.dim(`${r.keyHint ?? ""} · ${r.baseUrl ?? ""} · ${r.format ?? ""}`)}`
|
|
5068
|
+
: r.runsOn === "platform"
|
|
5069
|
+
? `${c.yellow("·")} the platform's credential ${c.dim("(this account is allowed it)")}`
|
|
5070
|
+
: `${c.red("✗")} no key — runs on this account are refused until one is set`;
|
|
5071
|
+
console.log(`\n ${runs}`);
|
|
5072
|
+
console.log(`\n ${c.dim("foldrun account model set <provider> --key <api key> · remove · a provider: block in AGENTS.md wins over it")}\n`);
|
|
5073
|
+
};
|
|
5074
|
+
if (!verb) {
|
|
5075
|
+
const r = await remoteCall(url, flags, "/api/account/model");
|
|
5076
|
+
if (flags.json === true) console.log(JSON.stringify(r, null, 2));
|
|
5077
|
+
else show(r);
|
|
5078
|
+
return r.runsOn === "none" ? 1 : 0;
|
|
5079
|
+
}
|
|
5080
|
+
if (verb === "remove") {
|
|
5081
|
+
const r = await remoteCall(url, flags, "/api/account/model", { method: "DELETE" });
|
|
5082
|
+
if (flags.json === true) console.log(JSON.stringify(r, null, 2));
|
|
5083
|
+
else show(r);
|
|
5084
|
+
return 0;
|
|
5085
|
+
}
|
|
5086
|
+
if (verb !== "set") throw new Error("`foldrun account model`, `foldrun account model set <provider> --key <api key>`, or `foldrun account model remove`");
|
|
5087
|
+
const provider = positional[1];
|
|
5088
|
+
if (!provider) throw new Error("which provider? `foldrun account model set anthropic --key …` (a preset name, or `custom --base-url https://… --format openai`)");
|
|
5089
|
+
let apiKey = typeof flags.key === "string" ? flags.key : "";
|
|
5090
|
+
if (apiKey === "-") apiKey = fs.readFileSync(0, "utf8").trim();
|
|
5091
|
+
if (!apiKey) throw new Error("--key <api key> (or --key - to read it from stdin)");
|
|
5092
|
+
const body = {
|
|
5093
|
+
provider,
|
|
5094
|
+
apiKey,
|
|
5095
|
+
...(typeof flags["base-url"] === "string" ? { baseUrl: flags["base-url"] } : {}),
|
|
5096
|
+
...(typeof flags.format === "string" ? { format: flags.format } : {}),
|
|
5097
|
+
};
|
|
5098
|
+
const r = await remoteCall(url, flags, "/api/account/model", {
|
|
5099
|
+
method: "PUT",
|
|
5100
|
+
body: JSON.stringify(body),
|
|
5101
|
+
headers: { "idempotency-key": crypto.randomUUID() },
|
|
5102
|
+
});
|
|
5103
|
+
if (flags.json === true) console.log(JSON.stringify(r, null, 2));
|
|
5104
|
+
else show(r);
|
|
5105
|
+
return 0;
|
|
5106
|
+
}
|
|
5107
|
+
|
|
4753
5108
|
// --------------------------------------------------------------- billing
|
|
4754
5109
|
|
|
4755
5110
|
/**
|
|
@@ -4847,9 +5202,10 @@ async function accountCmd(positional, flags) {
|
|
|
4847
5202
|
return 0;
|
|
4848
5203
|
}
|
|
4849
5204
|
if (verb === "providers") return providersCmd(url, flags);
|
|
5205
|
+
if (verb === "model") return accountModelCmd(url, positional.slice(1), flags);
|
|
4850
5206
|
if (verb === "export") return accountExportCmd(url, flags);
|
|
4851
5207
|
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`);
|
|
5208
|
+
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
5209
|
}
|
|
4854
5210
|
|
|
4855
5211
|
const key = positional[1];
|
|
@@ -5160,6 +5516,12 @@ const waitingSteps = (run) =>
|
|
|
5160
5516
|
.map((s, i) => ({ step: s, index: i }))
|
|
5161
5517
|
.filter(({ step }) => step.status === "awaiting-approval" && step.waitFor !== "event");
|
|
5162
5518
|
|
|
5519
|
+
/** The steps of a run parked on `wait: event` — what `approve --payload` releases. */
|
|
5520
|
+
const eventSteps = (run) =>
|
|
5521
|
+
(run.steps ?? [])
|
|
5522
|
+
.map((s, i) => ({ step: s, index: i }))
|
|
5523
|
+
.filter(({ step }) => step.status === "awaiting-approval" && step.waitFor === "event");
|
|
5524
|
+
|
|
5163
5525
|
// ------------------------------------------------------------- approvals
|
|
5164
5526
|
|
|
5165
5527
|
/**
|
|
@@ -5252,17 +5614,23 @@ async function decideCmd(decision, runId, flags, layout) {
|
|
|
5252
5614
|
}
|
|
5253
5615
|
const ws = await workspaceOfRun(url, flags, layout, runId);
|
|
5254
5616
|
const run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
|
|
5255
|
-
|
|
5617
|
+
// --payload releases a `wait: event` step as its event URL would, so those
|
|
5618
|
+
// are the steps it chooses from; every other decision is a person's gate.
|
|
5619
|
+
const payload = flags.payload === undefined ? undefined : eventPayload(flags.payload);
|
|
5620
|
+
if (payload !== undefined && decision !== "approve") throw new Error("--payload releases a `wait: event` step — it cannot go with reject");
|
|
5621
|
+
const waiting = payload === undefined ? waitingSteps(run) : eventSteps(run);
|
|
5256
5622
|
if (!waiting.length) {
|
|
5257
5623
|
throw new Error(
|
|
5258
|
-
|
|
5624
|
+
payload === undefined
|
|
5625
|
+
? `${runId} in ${ws} is ${run.status} — no step of it is waiting on a person. \`foldrun approvals\` lists the ones that are`
|
|
5626
|
+
: `${runId} in ${ws} is ${run.status} — no step of it is waiting on \`wait: event\`, which is what --payload releases`,
|
|
5259
5627
|
);
|
|
5260
5628
|
}
|
|
5261
5629
|
|
|
5262
5630
|
const only = flags.step === undefined ? null : Number(flags.step) - 1;
|
|
5263
5631
|
if (only !== null && !waiting.some(({ index }) => index === only)) {
|
|
5264
5632
|
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"}`,
|
|
5633
|
+
`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
5634
|
);
|
|
5267
5635
|
}
|
|
5268
5636
|
const chosen = only === null ? waiting : waiting.filter(({ index }) => index === only);
|
|
@@ -5287,7 +5655,12 @@ async function decideCmd(decision, runId, flags, layout) {
|
|
|
5287
5655
|
return 1;
|
|
5288
5656
|
}
|
|
5289
5657
|
|
|
5290
|
-
const body = {
|
|
5658
|
+
const body = {
|
|
5659
|
+
decision,
|
|
5660
|
+
...(note ? { note, ...(decision === "reject" ? { reason: note } : {}) } : {}),
|
|
5661
|
+
...(only === null ? {} : { step: only }),
|
|
5662
|
+
...(payload === undefined ? {} : { payload }),
|
|
5663
|
+
};
|
|
5291
5664
|
const answer = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}/approve`, {
|
|
5292
5665
|
method: "POST",
|
|
5293
5666
|
body: JSON.stringify(body),
|
|
@@ -5300,6 +5673,23 @@ async function decideCmd(decision, runId, flags, layout) {
|
|
|
5300
5673
|
return 0;
|
|
5301
5674
|
}
|
|
5302
5675
|
|
|
5676
|
+
/** `--payload` for a `wait: event` step: text, or `@file`; JSON is sent as
|
|
5677
|
+
* JSON, anything else as the text it is — what the event URL would take. */
|
|
5678
|
+
function eventPayload(value) {
|
|
5679
|
+
if (value === true) throw new Error("--payload needs a value — text, JSON, or @file");
|
|
5680
|
+
let text = String(value);
|
|
5681
|
+
if (text.startsWith("@")) {
|
|
5682
|
+
const file = path.resolve(text.slice(1));
|
|
5683
|
+
if (!fs.existsSync(file)) throw new Error(`--payload ${text}: no such file`);
|
|
5684
|
+
text = fs.readFileSync(file, "utf8");
|
|
5685
|
+
}
|
|
5686
|
+
try {
|
|
5687
|
+
return JSON.parse(text);
|
|
5688
|
+
} catch {
|
|
5689
|
+
return text;
|
|
5690
|
+
}
|
|
5691
|
+
}
|
|
5692
|
+
|
|
5303
5693
|
// ------------------------------------------------------------------ stop
|
|
5304
5694
|
|
|
5305
5695
|
/**
|
|
@@ -5844,6 +6234,308 @@ async function checkRemote(flags, layout) {
|
|
|
5844
6234
|
}
|
|
5845
6235
|
}
|
|
5846
6236
|
|
|
6237
|
+
// ---------------------------------------------------------------- hooks
|
|
6238
|
+
|
|
6239
|
+
/**
|
|
6240
|
+
* Core's hooks module — readHookDef, runHook, the event list — or null on a
|
|
6241
|
+
* core from before hooks, where `check` then says nothing about them and the
|
|
6242
|
+
* `hook` verbs say which core they need.
|
|
6243
|
+
*/
|
|
6244
|
+
async function hooksCore() {
|
|
6245
|
+
try {
|
|
6246
|
+
return /** @type {any} */ (await import("@foldrun/core/hooks"));
|
|
6247
|
+
} catch {
|
|
6248
|
+
return null;
|
|
6249
|
+
}
|
|
6250
|
+
}
|
|
6251
|
+
async function needHooksCore() {
|
|
6252
|
+
const h = await hooksCore();
|
|
6253
|
+
if (!h) throw new Error("this CLI's @foldrun/core has no hooks — update it (npm i -g foldrun@latest)");
|
|
6254
|
+
return h;
|
|
6255
|
+
}
|
|
6256
|
+
|
|
6257
|
+
/** The account library a hook verb works on: the folder beside workspaces/. */
|
|
6258
|
+
const libraryRootOf = (layout) => path.join(layout.accountRoot, "library");
|
|
6259
|
+
|
|
6260
|
+
/** What a new run.mjs answers, per event: a gate allows, a watcher notes nothing. */
|
|
6261
|
+
const HOOK_ANSWER = {
|
|
6262
|
+
"tool.call": `{ decision: "allow" }`,
|
|
6263
|
+
"tool.result": `{}`,
|
|
6264
|
+
"step.start": `{}`,
|
|
6265
|
+
"step.end": `{}`,
|
|
6266
|
+
};
|
|
6267
|
+
|
|
6268
|
+
/** A sample event per kind, for `hook test` with no --event. */
|
|
6269
|
+
const HOOK_SAMPLE = {
|
|
6270
|
+
"tool.call": { tool: { name: "Bash", input: { command: "ls" } } },
|
|
6271
|
+
"tool.result": { tool: { name: "Bash", input: { command: "ls" }, result: "agent.md\n" } },
|
|
6272
|
+
"step.start": { prompt: "Find three sources." },
|
|
6273
|
+
"step.end": { reply: "Done." },
|
|
6274
|
+
};
|
|
6275
|
+
|
|
6276
|
+
function hookRunTemplate(name, on) {
|
|
6277
|
+
return `// ${name} — a foldrun hook (on: ${on}). Self-contained: Node's built-ins only,
|
|
6278
|
+
// no relative imports — it runs from a copy taken when the step starts.
|
|
6279
|
+
//
|
|
6280
|
+
// In: one JSON object on stdin — the event:
|
|
6281
|
+
// { event, hook, run_id, step, agent, workspace,
|
|
6282
|
+
// tool: { name, input } (tool.call, tool.result; + result on tool.result)
|
|
6283
|
+
// prompt (step.start)
|
|
6284
|
+
// reply (step.end) }
|
|
6285
|
+
// Out: one JSON object on stdout, the last line printed:
|
|
6286
|
+
// tool.call { decision: "allow" | "deny", reason, input, note }
|
|
6287
|
+
// input replaces the tool's whole input; a deny needs a reason
|
|
6288
|
+
// step.start { context, note } context is added to the step's prompt
|
|
6289
|
+
// step.end { reply, note } reply replaces the step's reply
|
|
6290
|
+
// tool.result { note } a line on the run's trail
|
|
6291
|
+
// Exit 0 with an answer. Exit 2 denies, with stderr as the reason.
|
|
6292
|
+
// Anything else is a failure: a gate (tool.call, step.start) that fails
|
|
6293
|
+
// refuses; a watcher (tool.result, step.end) that fails is recorded.
|
|
6294
|
+
// No secrets and no model key reach this process.
|
|
6295
|
+
|
|
6296
|
+
let raw = "";
|
|
6297
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
6298
|
+
const event = JSON.parse(raw || "{}");
|
|
6299
|
+
|
|
6300
|
+
// Decide here. This one allows everything.
|
|
6301
|
+
void event;
|
|
6302
|
+
console.log(JSON.stringify(${HOOK_ANSWER[on]}));
|
|
6303
|
+
`;
|
|
6304
|
+
}
|
|
6305
|
+
|
|
6306
|
+
/** `foldrun hook <ls|add|test|rm>` — hooks in this workspace and the library. */
|
|
6307
|
+
async function hookCmd(positional, flags, layout) {
|
|
6308
|
+
const [verb = "ls"] = positional;
|
|
6309
|
+
if (verb === "ls" || verb === "list") return hookLsCmd(flags, layout);
|
|
6310
|
+
if (verb === "add" || verb === "new") return hookAddCmd(positional, flags, layout);
|
|
6311
|
+
if (verb === "test") return hookTestCmd(positional, flags, layout);
|
|
6312
|
+
if (verb === "rm") return hookRmCmd(positional, flags, layout);
|
|
6313
|
+
throw new Error(`unknown hook verb "${verb}" — ls, add <name> --on <event>, test <name>, rm <name>`);
|
|
6314
|
+
}
|
|
6315
|
+
|
|
6316
|
+
/** Every hook folder here, the workspace's first, each read the way a run reads it. */
|
|
6317
|
+
async function hooksHere(flags, layout) {
|
|
6318
|
+
const h = await needHooksCore();
|
|
6319
|
+
const ws = workspaceDirFor(flags, layout);
|
|
6320
|
+
const out = [];
|
|
6321
|
+
const seen = new Set();
|
|
6322
|
+
for (const [root, scope] of [[ws, "workspace"], [libraryRootOf(layout), "account"]]) {
|
|
6323
|
+
for (const dir of h.listHookDirs(path.join(root, "hooks"))) {
|
|
6324
|
+
const name = path.basename(dir);
|
|
6325
|
+
const def = h.readHookDef(dir, scope);
|
|
6326
|
+
const row = "error" in def
|
|
6327
|
+
? { name, scope, error: def.error }
|
|
6328
|
+
: { name, scope, on: def.on, tool: def.tool ?? null, timeout: def.timeoutSec, description: def.description ?? null };
|
|
6329
|
+
// A workspace hook of the same name is the one a run resolves.
|
|
6330
|
+
out.push({ ...row, ...(seen.has(name) ? { shadowed: true } : {}) });
|
|
6331
|
+
seen.add(name);
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
return { h, ws, hooks: out };
|
|
6335
|
+
}
|
|
6336
|
+
|
|
6337
|
+
/** `hook ls --to <ws>`: the DEPLOYED workspace's hooks and what they did
|
|
6338
|
+
* lately — GET /api/workspaces/<ws>/hooks, the dashboard's Hooks panel. */
|
|
6339
|
+
async function hookLsRemote(flags) {
|
|
6340
|
+
const url = platformFor(flags, "hook ls");
|
|
6341
|
+
const ws = String(flags.to);
|
|
6342
|
+
const q = new URLSearchParams();
|
|
6343
|
+
if (flags.all === true) q.set("all", "1");
|
|
6344
|
+
if (flags.runs !== undefined) q.set("runs", String(flags.runs));
|
|
6345
|
+
const r = /** @type {{ hooks: any[], recent: any[] }} */ (await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(ws)}/hooks${q.size ? `?${q}` : ""}`));
|
|
6346
|
+
if (flags.json === true) {
|
|
6347
|
+
console.log(JSON.stringify(r, null, 2));
|
|
6348
|
+
return 0;
|
|
6349
|
+
}
|
|
6350
|
+
const hooks = r.hooks ?? [];
|
|
6351
|
+
if (!hooks.length) console.log(`\n ${c.dim(`no hooks in ${ws} or the account library`)}`);
|
|
6352
|
+
else {
|
|
6353
|
+
console.log();
|
|
6354
|
+
const w = Math.max(...hooks.map((x) => x.name.length));
|
|
6355
|
+
for (const x of hooks) {
|
|
6356
|
+
const where = x.scope === "account" ? "library" : "workspace";
|
|
6357
|
+
if (x.error) {
|
|
6358
|
+
console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
|
|
6359
|
+
continue;
|
|
6360
|
+
}
|
|
6361
|
+
const on = (x.onFor ?? []).length ? `on for ${x.onFor.join(", ")}` : "turned on by nobody";
|
|
6362
|
+
const note = x.shadowed ? "(the workspace's own of this name is the one that runs)" : `${on}${x.description ? ` — ${x.description}` : ""}`;
|
|
6363
|
+
console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${String(x.on).padEnd(11)} ${String(x.tool ?? "*").padEnd(12)} ${c.dim(note)}`);
|
|
6364
|
+
}
|
|
6365
|
+
}
|
|
6366
|
+
const recent = r.recent ?? [];
|
|
6367
|
+
console.log(`\n ${c.bold("lately")} ${c.dim(recent.length ? `${recent.length} ${flags.all === true ? "hook decision" : "deny, rewrite or failure"}${recent.length === 1 ? "" : "s"}, newest first` : flags.all === true ? "no hook ran" : "nothing denied, rewritten or failed (--all for every decision)")}`);
|
|
6368
|
+
for (const e of recent.slice(0, 20)) {
|
|
6369
|
+
const rec = e.record ?? {};
|
|
6370
|
+
console.log(` ${c.dim(String(e.t).slice(0, 16).replace("T", " "))} ${rec.hook} ${rec.did} ${rec.event}${rec.tool ? ` ${rec.tool}` : ""} ${c.dim(`${e.runId} step ${e.step} ${e.agent}`)}${rec.reason ? ` — ${firstLine(rec.reason, 80)}` : ""}`);
|
|
6371
|
+
}
|
|
6372
|
+
console.log(`\n ${c.dim("--runs <n> reads further back (default 50) · --all for every decision · --json")}\n`);
|
|
6373
|
+
return hooks.some((x) => x.error) ? 1 : 0;
|
|
6374
|
+
}
|
|
6375
|
+
|
|
6376
|
+
async function hookLsCmd(flags, layout) {
|
|
6377
|
+
if (typeof flags.to === "string") return hookLsRemote(flags);
|
|
6378
|
+
const { hooks } = await hooksHere(flags, layout);
|
|
6379
|
+
if (flags.json === true) {
|
|
6380
|
+
console.log(JSON.stringify(hooks, null, 2));
|
|
6381
|
+
return 0;
|
|
6382
|
+
}
|
|
6383
|
+
if (!hooks.length) {
|
|
6384
|
+
console.log(`\n ${c.dim("no hooks — `foldrun hook add <name> --on tool.call` makes one")}\n`);
|
|
6385
|
+
return 0;
|
|
6386
|
+
}
|
|
6387
|
+
console.log();
|
|
6388
|
+
const w = Math.max(...hooks.map((x) => x.name.length));
|
|
6389
|
+
for (const x of hooks) {
|
|
6390
|
+
const where = x.scope === "account" ? "library" : "workspace";
|
|
6391
|
+
if (x.error) {
|
|
6392
|
+
console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
|
|
6393
|
+
continue;
|
|
6394
|
+
}
|
|
6395
|
+
const what = `${x.on.padEnd(11)} ${(x.tool ?? "*").padEnd(12)}`;
|
|
6396
|
+
const note = x.shadowed ? c.dim("(the workspace's own of this name is the one that runs)") : c.dim(x.description ?? "");
|
|
6397
|
+
console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${what} ${note}`);
|
|
6398
|
+
}
|
|
6399
|
+
console.log(`\n ${c.dim("an agent turns one on with hooks: [name]; AGENTS.md hooks: turns it on for every agent under it")}\n`);
|
|
6400
|
+
return hooks.some((x) => x.error) ? 1 : 0;
|
|
6401
|
+
}
|
|
6402
|
+
|
|
6403
|
+
async function hookAddCmd(positional, flags, layout) {
|
|
6404
|
+
const h = await needHooksCore();
|
|
6405
|
+
const name = positional[1];
|
|
6406
|
+
if (!name) throw new Error("which hook? `foldrun hook add <name> --on tool.call|tool.result|step.start|step.end`");
|
|
6407
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
|
6408
|
+
throw new Error(`"${name}" is not a hook name — lowercase letters, digits and dashes, e.g. no-rm-rf`);
|
|
6409
|
+
}
|
|
6410
|
+
const on = flags.on;
|
|
6411
|
+
if (typeof on !== "string" || !h.HOOK_EVENTS.includes(on)) {
|
|
6412
|
+
throw new Error(`--on takes ${h.HOOK_EVENTS.join(", ")}${typeof on === "string" ? ` — not "${on}"` : ""}`);
|
|
6413
|
+
}
|
|
6414
|
+
const tool = typeof flags.tool === "string" ? flags.tool.trim() : undefined;
|
|
6415
|
+
if (flags.tool !== undefined && !tool) throw new Error("--tool takes a tool name or pattern, like Bash, Write|Edit or mcp__*");
|
|
6416
|
+
if (tool && on !== "tool.call" && on !== "tool.result") {
|
|
6417
|
+
throw new Error(`--tool only means something on tool.call and tool.result, not ${on}`);
|
|
6418
|
+
}
|
|
6419
|
+
const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
|
|
6420
|
+
const rel = `hooks/${name}`;
|
|
6421
|
+
const front = [
|
|
6422
|
+
"---",
|
|
6423
|
+
`on: ${on}`,
|
|
6424
|
+
...(tool ? [`tool: ${/[|*:]/.test(tool) ? JSON.stringify(tool) : tool}`] : []),
|
|
6425
|
+
`description: What ${name} checks, in one line.`,
|
|
6426
|
+
"---",
|
|
6427
|
+
"",
|
|
6428
|
+
`What ${name} is for, and what it refuses or rewrites. The code is run.mjs beside this file.`,
|
|
6429
|
+
"",
|
|
6430
|
+
].join("\n");
|
|
6431
|
+
writeFiles(root, [
|
|
6432
|
+
{ path: `${rel}/hook.md`, content: front },
|
|
6433
|
+
{ path: `${rel}/run.mjs`, content: hookRunTemplate(name, on) },
|
|
6434
|
+
], flags.library === true ? "the library" : path.basename(root));
|
|
6435
|
+
const where = path.relative(process.cwd(), path.join(root, rel)) || rel;
|
|
6436
|
+
console.log(`\n ${c.green("created")} hook ${c.bold(name)} ${c.dim(`(${on}${tool ? ` · ${tool}` : ""})`)} in ${where}\n`);
|
|
6437
|
+
console.log(` ${c.dim(`${rel}/hook.md`)}\n ${c.dim(`${rel}/run.mjs`)}`);
|
|
6438
|
+
console.log(`\n ${c.bold("Next")}
|
|
6439
|
+
edit ${path.join(where, "run.mjs")}
|
|
6440
|
+
hooks: [${name}]${" ".repeat(4)}${c.dim("in an agent's frontmatter — or in AGENTS.md for every agent")}
|
|
6441
|
+
foldrun hook test ${name}${" ".repeat(4)}${c.dim("run it on a sample event, here")}\n`);
|
|
6442
|
+
return 0;
|
|
6443
|
+
}
|
|
6444
|
+
|
|
6445
|
+
/** --event: inline JSON, or @file. */
|
|
6446
|
+
function readHookEvent(raw, on) {
|
|
6447
|
+
if (raw === undefined) return { event: HOOK_SAMPLE[on] ?? {}, sample: true };
|
|
6448
|
+
if (typeof raw !== "string") throw new Error("--event takes a JSON object, or @file");
|
|
6449
|
+
const text = raw.startsWith("@") ? fs.readFileSync(raw.slice(1), "utf8") : raw;
|
|
6450
|
+
let event;
|
|
6451
|
+
try { event = JSON.parse(text); } catch (err) {
|
|
6452
|
+
throw new Error(`--event is not JSON (${err instanceof Error ? err.message : err})`);
|
|
6453
|
+
}
|
|
6454
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("--event is one JSON object — the event the hook reads on stdin");
|
|
6455
|
+
return { event, sample: false };
|
|
6456
|
+
}
|
|
6457
|
+
|
|
6458
|
+
function printHookAnswer(name, on, a, where) {
|
|
6459
|
+
const mark = !a.ok ? c.red("✗") : a.decision === "deny" ? c.amber("■") : c.green("✓");
|
|
6460
|
+
console.log(`\n ${mark} ${c.bold(name)} ${c.dim(`${on ?? "?"} · ${where} · ${a.ms ?? 0}ms`)}`);
|
|
6461
|
+
if (!a.ok) console.log(` ${c.red("failed")} ${a.failure ?? "no reason given"}${on === "tool.call" || on === "step.start" ? c.dim(" — a gate that fails refuses") : ""}`);
|
|
6462
|
+
if (a.decision) console.log(` ${pad("decision", 9)} ${a.decision === "deny" ? c.amber("deny") : "allow"}`);
|
|
6463
|
+
if (a.reason) console.log(` ${pad("reason", 9)} ${a.reason}`);
|
|
6464
|
+
if (a.input) console.log(` ${pad("input", 9)} ${JSON.stringify(a.input)}`);
|
|
6465
|
+
if (a.context) console.log(` ${pad("context", 9)} ${a.context}`);
|
|
6466
|
+
if (a.reply !== undefined) console.log(` ${pad("reply", 9)} ${a.reply}`);
|
|
6467
|
+
if (a.note) console.log(` ${pad("note", 9)} ${a.note}`);
|
|
6468
|
+
console.log();
|
|
6469
|
+
return a.ok ? 0 : 1;
|
|
6470
|
+
}
|
|
6471
|
+
|
|
6472
|
+
/**
|
|
6473
|
+
* `foldrun hook test <name> [--event '<json>']` — run one hook on one event,
|
|
6474
|
+
* here, the way a step runs it (core's runHook: the event on stdin, a
|
|
6475
|
+
* stripped environment, its timeout). `--to <workspace>` asks the platform
|
|
6476
|
+
* to run the deployed one instead.
|
|
6477
|
+
*/
|
|
6478
|
+
async function hookTestCmd(positional, flags, layout) {
|
|
6479
|
+
const name = positional[1];
|
|
6480
|
+
if (!name) throw new Error("which hook? `foldrun hook test <name> --event '<json>'` — `foldrun hook ls` lists them");
|
|
6481
|
+
if (typeof flags.to === "string") {
|
|
6482
|
+
const url = platformFor(flags, "hook test");
|
|
6483
|
+
const ws = flags.to;
|
|
6484
|
+
let event = {};
|
|
6485
|
+
if (flags.event !== undefined) event = readHookEvent(flags.event, "tool.call").event;
|
|
6486
|
+
const r = /** @type {any} */ (await remoteCall(
|
|
6487
|
+
url,
|
|
6488
|
+
flags,
|
|
6489
|
+
`/api/workspaces/${enc(ws)}/hooks/${enc(name)}/test`,
|
|
6490
|
+
{ method: "POST", body: JSON.stringify({ event }) },
|
|
6491
|
+
));
|
|
6492
|
+
const a = r?.answer ?? r ?? {};
|
|
6493
|
+
return printHookAnswer(name, r?.on ?? a.on, { ok: a.ok !== false, ...a }, ws);
|
|
6494
|
+
}
|
|
6495
|
+
const h = await needHooksCore();
|
|
6496
|
+
const ws = workspaceDirFor({ ...flags, to: undefined }, layout);
|
|
6497
|
+
const r = h.resolveHooks({ workspaceRoot: ws, libraryRoot: libraryRootOf(layout), names: [name] });
|
|
6498
|
+
if (r.missing.length) throw new Error(`no hook named ${name} in hooks/ or the library — \`foldrun hook ls\` lists them`);
|
|
6499
|
+
if (r.errors.length) throw new Error(r.errors[0]);
|
|
6500
|
+
const def = r.hooks[0];
|
|
6501
|
+
const { event, sample } = readHookEvent(flags.event, def.on);
|
|
6502
|
+
if (sample) console.log(`\n ${c.dim(`no --event, so a sample ${def.on} event: ${JSON.stringify(event)}`)}`);
|
|
6503
|
+
const full = { event: def.on, hook: def.name, run_id: null, step: null, agent: null, workspace: path.basename(ws), ...event };
|
|
6504
|
+
const a = await h.runHook(def, full, { cwd: ws, env: process.env });
|
|
6505
|
+
return printHookAnswer(name, def.on, a, def.scope === "account" ? "library" : path.basename(ws));
|
|
6506
|
+
}
|
|
6507
|
+
|
|
6508
|
+
async function hookRmCmd(positional, flags, layout) {
|
|
6509
|
+
const h = await needHooksCore();
|
|
6510
|
+
const name = positional[1];
|
|
6511
|
+
if (!name) throw new Error("which hook? `foldrun hook rm <name>` (--library for the account's)");
|
|
6512
|
+
const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
|
|
6513
|
+
const dir = path.join(root, "hooks", name);
|
|
6514
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name) || !fs.existsSync(dir)) {
|
|
6515
|
+
throw new Error(`no hooks/${name} in ${flags.library === true ? "the library" : path.basename(root)} — \`foldrun hook ls\` lists them`);
|
|
6516
|
+
}
|
|
6517
|
+
// Who still turns it on: they fail check, and their steps are refused.
|
|
6518
|
+
const users = [];
|
|
6519
|
+
const wsRoots = flags.library === true
|
|
6520
|
+
? (layout.workspacesDir ? layout.workspaces.map((w) => path.join(layout.workspacesDir, w)) : [layout.workspaceDir].filter(Boolean))
|
|
6521
|
+
: [root];
|
|
6522
|
+
const matter = (await import("gray-matter")).default;
|
|
6523
|
+
const names = (file) => {
|
|
6524
|
+
try { return h.hookNames(matter(fs.readFileSync(file, "utf8")).data?.hooks); } catch { return []; }
|
|
6525
|
+
};
|
|
6526
|
+
for (const w of wsRoots) {
|
|
6527
|
+
if (names(path.join(w, "AGENTS.md")).includes(name)) users.push(`${path.basename(w)}/AGENTS.md`);
|
|
6528
|
+
for (const a of ls(path.join(w, "agents"))) {
|
|
6529
|
+
if (names(path.join(w, "agents", a, "agent.md")).includes(name)) users.push(`${path.basename(w)}/agents/${a}`);
|
|
6530
|
+
}
|
|
6531
|
+
}
|
|
6532
|
+
const still = users.length ? ` — still turned on by ${users.join(", ")}, whose steps will be refused until it goes from their hooks:` : "";
|
|
6533
|
+
if (!(await sureToDelete(flags, "hook rm", `${flags.library === true ? "library/" : ""}hooks/${name}${still}`))) return 1;
|
|
6534
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
6535
|
+
console.log(`\n ${c.green("✓")} removed hooks/${name}${users.length ? c.dim(` — take it out of: ${users.join(", ")}`) : ""}\n`);
|
|
6536
|
+
return 0;
|
|
6537
|
+
}
|
|
6538
|
+
|
|
5847
6539
|
// ------------------------------------------- flows and teams, as the canvas edits them
|
|
5848
6540
|
//
|
|
5849
6541
|
// The dashboard's flow canvas turns every palette block into a small, exact
|
|
@@ -6367,6 +7059,106 @@ function hasDescription(raw) {
|
|
|
6367
7059
|
return /^\s+\S/.test(lines[at + 1] ?? "");
|
|
6368
7060
|
}
|
|
6369
7061
|
|
|
7062
|
+
/** What an import leaves behind: what the agent accumulated, not what it is.
|
|
7063
|
+
* The same rule as core's agent-import.ts. */
|
|
7064
|
+
const NOT_IMPORTED = /^(memory|outputs|state|workspace|\.claude)(\/|$)|^\.claude\.json$/;
|
|
7065
|
+
|
|
7066
|
+
/**
|
|
7067
|
+
* `foldrun agent import <workspace>/<agent> [--as <name>]` — bring an agent
|
|
7068
|
+
* you already have in another workspace into this one: its agent.md and its
|
|
7069
|
+
* own skills/, scripts/, knowledge/ — never its memory. With `--to <ws>` it
|
|
7070
|
+
* is the platform's POST /api/workspaces/<ws>/agents/import; without, it
|
|
7071
|
+
* copies between workspaces of this account folder, into the one you stand
|
|
7072
|
+
* in (or `--workspace <name>`).
|
|
7073
|
+
*/
|
|
7074
|
+
async function agentImportCmd(positional, flags, layout) {
|
|
7075
|
+
// --list: what could be imported into a deployed workspace — every other
|
|
7076
|
+
// workspace this key may read, and its agents (GET …/agents/import).
|
|
7077
|
+
if (flags.list === true) {
|
|
7078
|
+
const url = platformFor(flags, "agent import --list");
|
|
7079
|
+
const ws = platformWorkspace(flags, layout, "agent import --list");
|
|
7080
|
+
const { workspaces = [] } = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/agents/import`);
|
|
7081
|
+
if (flags.json === true) {
|
|
7082
|
+
console.log(JSON.stringify(workspaces, null, 2));
|
|
7083
|
+
return 0;
|
|
7084
|
+
}
|
|
7085
|
+
if (!workspaces.length) {
|
|
7086
|
+
console.log(`\n ${c.dim(`nothing to import into ${ws} — no other workspace you can read has an agent`)}\n`);
|
|
7087
|
+
return 0;
|
|
7088
|
+
}
|
|
7089
|
+
const w = Math.max(...workspaces.flatMap((x) => (x.agents ?? []).map((a) => `${x.name}/${a.name}`.length)));
|
|
7090
|
+
console.log();
|
|
7091
|
+
for (const x of workspaces) {
|
|
7092
|
+
for (const a of x.agents ?? []) console.log(` ${c.bold(pad(`${x.name}/${a.name}`, w))} ${c.dim(firstLine(a.description ?? "", 80))}`);
|
|
7093
|
+
}
|
|
7094
|
+
console.log(`\n ${c.dim(`foldrun agent import <workspace>/<agent> --to ${ws} [--as <name>] copies one in`)}\n`);
|
|
7095
|
+
return 0;
|
|
7096
|
+
}
|
|
7097
|
+
const ref = positional[1];
|
|
7098
|
+
const m = typeof ref === "string" ? /^([^/]+)\/([^/]+)$/.exec(ref) : null;
|
|
7099
|
+
if (!m) throw new Error("which agent? `foldrun agent import <workspace>/<agent> [--as <name>] [--to <workspace>]`");
|
|
7100
|
+
const [, from, agent] = m;
|
|
7101
|
+
const as = typeof flags.as === "string" && flags.as.trim() ? flags.as.trim() : null;
|
|
7102
|
+
|
|
7103
|
+
if (typeof flags.to === "string") {
|
|
7104
|
+
const url = platformFor(flags, "agent import");
|
|
7105
|
+
const body = await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(flags.to)}/agents/import`, {
|
|
7106
|
+
method: "POST",
|
|
7107
|
+
body: JSON.stringify({ from, agent, ...(as ? { as } : {}) }),
|
|
7108
|
+
});
|
|
7109
|
+
console.log(`\n ${c.green("✓")} ${c.bold(body.name)} ${c.dim(`from ${from} → ${flags.to}`)}`);
|
|
7110
|
+
for (const f of body.files ?? []) console.log(` ${c.dim(f)}`);
|
|
7111
|
+
for (const w of body.warnings ?? []) console.log(` ${c.amber("!")} ${w}`);
|
|
7112
|
+
console.log();
|
|
7113
|
+
return 0;
|
|
7114
|
+
}
|
|
7115
|
+
|
|
7116
|
+
if (!layout.workspacesDir) {
|
|
7117
|
+
throw new Error("not in an account folder — pass --to <workspace> to import on the platform");
|
|
7118
|
+
}
|
|
7119
|
+
const name = as ?? agent;
|
|
7120
|
+
assertName(name);
|
|
7121
|
+
const src = path.join(layout.workspacesDir, from, "agents", agent);
|
|
7122
|
+
if (!fs.existsSync(path.join(src, "agent.md"))) {
|
|
7123
|
+
throw new Error(`no agent "${agent}" in ${from} — ${layout.workspaces.includes(from) ? "" : `this account has ${layout.workspaces.join(", ") || "no workspaces"}`}`.replace(/ — $/, ""));
|
|
7124
|
+
}
|
|
7125
|
+
const target =
|
|
7126
|
+
typeof flags.workspace === "string"
|
|
7127
|
+
? path.join(layout.workspacesDir, flags.workspace)
|
|
7128
|
+
: layout.workspaceDir && layout.kind !== "empty"
|
|
7129
|
+
? layout.workspaceDir
|
|
7130
|
+
: null;
|
|
7131
|
+
if (!target || !fs.existsSync(target)) throw new Error("into which workspace? cd into it, or --workspace <name>");
|
|
7132
|
+
if (path.resolve(target) === path.resolve(path.join(layout.workspacesDir, from)) && name === agent) {
|
|
7133
|
+
throw new Error(`${agent} is already in ${from} — give the copy another name with --as`);
|
|
7134
|
+
}
|
|
7135
|
+
const dest = path.join(target, "agents", name);
|
|
7136
|
+
if (fs.existsSync(dest)) throw new Error(`${path.basename(target)} already has an agent called "${name}" — import it under another name with --as`);
|
|
7137
|
+
|
|
7138
|
+
const written = [];
|
|
7139
|
+
const walk = (dir, rel = "") => {
|
|
7140
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
7141
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
7142
|
+
if (NOT_IMPORTED.test(r) || e.isSymbolicLink()) continue;
|
|
7143
|
+
if (e.isDirectory()) walk(path.join(dir, e.name), r);
|
|
7144
|
+
else if (e.isFile()) {
|
|
7145
|
+
let content = fs.readFileSync(path.join(dir, e.name));
|
|
7146
|
+
if (r === "agent.md" && name !== agent) {
|
|
7147
|
+
content = Buffer.from(content.toString("utf8").replace(/^(---\r?\n(?:[\s\S]*?\r?\n)?)name:[^\n]*$/m, `$1name: ${name}`));
|
|
7148
|
+
}
|
|
7149
|
+
fs.mkdirSync(path.dirname(path.join(dest, r)), { recursive: true });
|
|
7150
|
+
fs.writeFileSync(path.join(dest, r), content);
|
|
7151
|
+
written.push(`agents/${name}/${r}`);
|
|
7152
|
+
}
|
|
7153
|
+
}
|
|
7154
|
+
};
|
|
7155
|
+
walk(src);
|
|
7156
|
+
console.log(`\n ${c.green("✓")} ${c.bold(name)} ${c.dim(`from ${from} → ${path.basename(target)}`)}`);
|
|
7157
|
+
for (const f of written) console.log(` ${c.dim(f)}`);
|
|
7158
|
+
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`);
|
|
7159
|
+
return 0;
|
|
7160
|
+
}
|
|
7161
|
+
|
|
6370
7162
|
/**
|
|
6371
7163
|
* `foldrun agent link <agent> --subagent <worker> | --consult <agent> |
|
|
6372
7164
|
* --can-ask`, and `unlink` — an agent's team, edited in its frontmatter
|
|
@@ -6560,6 +7352,27 @@ async function flowShowCmd(positional, flags, layout) {
|
|
|
6560
7352
|
return errors ? 1 : 0;
|
|
6561
7353
|
}
|
|
6562
7354
|
|
|
7355
|
+
/**
|
|
7356
|
+
* `foldrun flow hook <flow>` — the flow's webhook URL as it is now, changing
|
|
7357
|
+
* nothing. Before this the only way to learn it from here was rotate-hook,
|
|
7358
|
+
* which broke the URL whoever was already posting to it had.
|
|
7359
|
+
*/
|
|
7360
|
+
async function flowHookCmd(positional, flags, layout) {
|
|
7361
|
+
const flow = positional[1];
|
|
7362
|
+
if (!flow) throw new Error("which flow? `foldrun flow hook <flow> --to <workspace>`");
|
|
7363
|
+
const url = platformFor(flags, "flow hook");
|
|
7364
|
+
const ws = platformWorkspace(flags, layout, `flow hook ${flow}`);
|
|
7365
|
+
const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/hooks/${enc(flow)}`);
|
|
7366
|
+
const full = r.url ?? new URL(r.path, url).toString();
|
|
7367
|
+
if (flags.json) {
|
|
7368
|
+
console.log(JSON.stringify({ flow, url: full, path: r.path }));
|
|
7369
|
+
return 0;
|
|
7370
|
+
}
|
|
7371
|
+
console.log(`\n ${c.bold(full)}`);
|
|
7372
|
+
console.log(` ${c.dim("a credential — whoever has it can start this flow; `foldrun flow rotate-hook " + flow + "` replaces it")}\n`);
|
|
7373
|
+
return 0;
|
|
7374
|
+
}
|
|
7375
|
+
|
|
6563
7376
|
/**
|
|
6564
7377
|
* `foldrun flow rotate-hook <flow>` — a new webhook URL for one flow. The
|
|
6565
7378
|
* old one stops working the moment this returns, so it asks first.
|
|
@@ -6652,9 +7465,20 @@ function bulkFilter(flags) {
|
|
|
6652
7465
|
if (typeof flags.status === "string") body.status = flags.status.split(",").map((s) => s.trim()).filter(Boolean);
|
|
6653
7466
|
if (typeof flags.flow === "string") body.flow = flags.flow;
|
|
6654
7467
|
if (flags.since !== undefined) body.since = new Date(Date.now() - parseSince(flags.since)).toISOString();
|
|
7468
|
+
if (flags.until !== undefined) body.until = untilInstant(flags.until);
|
|
6655
7469
|
return body;
|
|
6656
7470
|
}
|
|
6657
|
-
const hasBulkFilter = (flags) => typeof flags.status === "string" || typeof flags.flow === "string" || flags.since !== undefined;
|
|
7471
|
+
const hasBulkFilter = (flags) => typeof flags.status === "string" || typeof flags.flow === "string" || flags.since !== undefined || flags.until !== undefined;
|
|
7472
|
+
|
|
7473
|
+
/** `--until 2h` (runs started at least that long ago) or a date — as the
|
|
7474
|
+
* ISO instant runs/bulk reads. A value that is neither refuses: a clause
|
|
7475
|
+
* that silently fell out would widen the filter. */
|
|
7476
|
+
function untilInstant(spec) {
|
|
7477
|
+
if (/^\d+(\.\d+)?\s*[smhdw]$/i.test(String(spec).trim())) return new Date(Date.now() - parseSince(spec)).toISOString();
|
|
7478
|
+
const t = typeof spec === "string" ? Date.parse(spec) : NaN;
|
|
7479
|
+
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}"`);
|
|
7480
|
+
return new Date(t).toISOString();
|
|
7481
|
+
}
|
|
6658
7482
|
|
|
6659
7483
|
/**
|
|
6660
7484
|
* `foldrun stop|rerun --status … --since … --flow …` — one decision, many
|
|
@@ -6728,7 +7552,7 @@ async function runRmCmd(runId, flags, layout) {
|
|
|
6728
7552
|
/** Where downloaded bytes go: --file (or - for stdout), else a default name. Refuses to clobber. */
|
|
6729
7553
|
function writeDownload(bytes, fallback, flags, from) {
|
|
6730
7554
|
if (flags.file === "-") {
|
|
6731
|
-
|
|
7555
|
+
writeExact(bytes);
|
|
6732
7556
|
return 0;
|
|
6733
7557
|
}
|
|
6734
7558
|
const to = typeof flags.file === "string" ? flags.file : fallback;
|
|
@@ -6764,7 +7588,7 @@ async function apiCmd(positional, flags) {
|
|
|
6764
7588
|
const out = typeof flags.out === "string" ? flags.out : typeof flags.file === "string" ? flags.file : "-";
|
|
6765
7589
|
const pretty = JSON.stringify(doc, null, 2) + "\n";
|
|
6766
7590
|
if (out === "-") {
|
|
6767
|
-
|
|
7591
|
+
writeExact(pretty);
|
|
6768
7592
|
return 0;
|
|
6769
7593
|
}
|
|
6770
7594
|
fs.writeFileSync(out, pretty);
|
|
@@ -6791,6 +7615,100 @@ async function remoteBytes(url, flags, apiPath, what) {
|
|
|
6791
7615
|
return { bytes: Buffer.from(await res.arrayBuffer()), name };
|
|
6792
7616
|
}
|
|
6793
7617
|
|
|
7618
|
+
/**
|
|
7619
|
+
* `foldrun export [workspace] [--flow <name> | --agent <name>]` — a deployed
|
|
7620
|
+
* workspace, one flow or one agent as a .zip: the authored source it needs,
|
|
7621
|
+
* never memory, state, storage or a secret's value (GET …/export). The same
|
|
7622
|
+
* file the dashboard's Export downloads and `foldrun import` takes in.
|
|
7623
|
+
* @param {string[]} positional @param {Record<string, any>} flags
|
|
7624
|
+
*/
|
|
7625
|
+
async function exportCmd(positional, flags, layout) {
|
|
7626
|
+
const url = platformFor(flags, "export");
|
|
7627
|
+
const ws = positional[0] ?? platformWorkspace(flags, layout, "export");
|
|
7628
|
+
if (typeof flags.flow === "string" && typeof flags.agent === "string") throw new Error("one at a time: --flow <name> or --agent <name>");
|
|
7629
|
+
const kind = typeof flags.flow === "string" ? "flow" : typeof flags.agent === "string" ? "agent" : "workspace";
|
|
7630
|
+
const name = kind === "flow" ? flags.flow : kind === "agent" ? flags.agent : null;
|
|
7631
|
+
const qs = name ? `?kind=${kind}&name=${enc(name)}` : "";
|
|
7632
|
+
const { bytes, name: served } = await remoteBytes(url, flags, `/api/workspaces/${enc(ws)}/export${qs}`, `export ${name ? `${kind} ${name} from ` : ""}${ws}`);
|
|
7633
|
+
return writeDownload(bytes, served ?? (name ? `${ws}-${kind}-${name}.zip` : `${ws}.zip`), flags, name ? `${ws} · ${kind} ${name}` : ws);
|
|
7634
|
+
}
|
|
7635
|
+
|
|
7636
|
+
/**
|
|
7637
|
+
* `foldrun import <file.zip> [--to <workspace>]` — take a package in: shows
|
|
7638
|
+
* what it adds, would replace and still needs (POST …/import?dryRun=1), then
|
|
7639
|
+
* asks, then applies. Never deletes; replacing a file needs --overwrite. A
|
|
7640
|
+
* workspace package into a name that does not exist makes that workspace.
|
|
7641
|
+
* @param {string[]} positional @param {Record<string, any>} flags
|
|
7642
|
+
*/
|
|
7643
|
+
async function importCmd(positional, flags, layout) {
|
|
7644
|
+
const file = positional[0];
|
|
7645
|
+
if (!file) throw new Error("which package? `foldrun import <file.zip> [--to <workspace>]` — `foldrun export` or the dashboard's Export makes one");
|
|
7646
|
+
if (!fs.existsSync(file)) throw new Error(`${file} does not exist`);
|
|
7647
|
+
const url = platformFor(flags, "import");
|
|
7648
|
+
const ws = platformWorkspace(flags, layout, "import");
|
|
7649
|
+
const bytes = fs.readFileSync(file);
|
|
7650
|
+
const token = tokenFor(url, flags);
|
|
7651
|
+
const send = async (query) => {
|
|
7652
|
+
const res = await remoteFetch(
|
|
7653
|
+
url,
|
|
7654
|
+
`/api/workspaces/${enc(ws)}/import${query}`,
|
|
7655
|
+
{ method: "POST", body: bytes, headers: { "content-type": "application/zip" } },
|
|
7656
|
+
{ token, seconds: timeoutSeconds(flags) },
|
|
7657
|
+
);
|
|
7658
|
+
const text = await res.text();
|
|
7659
|
+
let body = {};
|
|
7660
|
+
try {
|
|
7661
|
+
body = text.trim() ? JSON.parse(text) : {};
|
|
7662
|
+
} catch {
|
|
7663
|
+
/* not JSON — the status says enough */
|
|
7664
|
+
}
|
|
7665
|
+
if (!res.ok) throw new HttpError(`${body.error ?? `import → HTTP ${res.status}`}${res.status === 401 || res.status === 403 ? refusedHint(url, flags) : ""}`, res.status, body);
|
|
7666
|
+
return body;
|
|
7667
|
+
};
|
|
7668
|
+
|
|
7669
|
+
const plan = await send("?dryRun=1");
|
|
7670
|
+
const what = `${plan.kind === "workspace" ? "workspace" : `${plan.kind} ${plan.name}`}${plan.from ? ` from ${plan.from}` : ""}`;
|
|
7671
|
+
if (flags.json === true && flags["dry-run"] === true) {
|
|
7672
|
+
console.log(JSON.stringify(plan, null, 2));
|
|
7673
|
+
return 0;
|
|
7674
|
+
}
|
|
7675
|
+
console.log(`\n ${c.bold(what)} → ${c.bold(ws)}${plan.creates ? c.dim(" (a new workspace)") : ""}`);
|
|
7676
|
+
console.log(` ${plan.added.length} new · ${plan.unchanged.length} identical · ${plan.overwritten.length ? c.amber(`${plan.overwritten.length} would be replaced`) : "0 replaced"}`);
|
|
7677
|
+
for (const p of plan.overwritten.slice(0, 20)) console.log(` ${c.amber("~")} ${p}`);
|
|
7678
|
+
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 ? " …" : ""}`)}`);
|
|
7679
|
+
const needs = [
|
|
7680
|
+
["secrets to set", plan.needs?.secrets],
|
|
7681
|
+
["tools not here or in the library", plan.needs?.tools],
|
|
7682
|
+
["skills not found", plan.needs?.skills],
|
|
7683
|
+
["scripts not found", plan.needs?.scripts],
|
|
7684
|
+
["agents it names that are not here", plan.needs?.agents],
|
|
7685
|
+
["flows it runs that are not here", plan.needs?.flows],
|
|
7686
|
+
].filter(([, v]) => v?.length);
|
|
7687
|
+
for (const [k, v] of needs) console.log(` ${c.amber("!")} ${k}: ${v.join(", ")}`);
|
|
7688
|
+
|
|
7689
|
+
if (flags["dry-run"] === true) {
|
|
7690
|
+
console.log(`\n ${c.dim("(--dry-run: nothing written)")}\n`);
|
|
7691
|
+
return 0;
|
|
7692
|
+
}
|
|
7693
|
+
if (plan.added.length + plan.overwritten.length === 0) {
|
|
7694
|
+
console.log(`\n ${c.dim("nothing to import — every file is already identical")}\n`);
|
|
7695
|
+
return 0;
|
|
7696
|
+
}
|
|
7697
|
+
if (plan.overwritten.length && flags.overwrite !== true) {
|
|
7698
|
+
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`);
|
|
7699
|
+
return 1;
|
|
7700
|
+
}
|
|
7701
|
+
if (!(await confirmed(flags, "import", `Import into ${ws}? [y/N] `))) {
|
|
7702
|
+
console.log(`\n ${c.dim("nothing written")}\n`);
|
|
7703
|
+
return 0;
|
|
7704
|
+
}
|
|
7705
|
+
const done = await send(flags.overwrite === true ? "?overwrite=1" : "");
|
|
7706
|
+
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}`) : ""}`);
|
|
7707
|
+
if (needs.length) console.log(` ${c.dim("set what it still needs before its first run — `foldrun secrets set <NAME>` for secrets")}`);
|
|
7708
|
+
console.log();
|
|
7709
|
+
return 0;
|
|
7710
|
+
}
|
|
7711
|
+
|
|
6794
7712
|
/**
|
|
6795
7713
|
* `foldrun report <run-id> get <agent>/<path>` — one file a run archived:
|
|
6796
7714
|
* whatever an agent left in outputs/, copied under the run when it ended.
|
|
@@ -6853,7 +7771,7 @@ async function observeCmd(flags, layout) {
|
|
|
6853
7771
|
const obs = (
|
|
6854
7772
|
await pool(names, 8, async (ws) => {
|
|
6855
7773
|
try {
|
|
6856
|
-
return await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/observe?
|
|
7774
|
+
return await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/observe?days=${since}`);
|
|
6857
7775
|
} catch (err) {
|
|
6858
7776
|
if (typeof flags.to === "string") throw err;
|
|
6859
7777
|
unreachable.push([ws, explain(err)]);
|
|
@@ -6957,7 +7875,7 @@ async function billingVerbCmd(positional, flags) {
|
|
|
6957
7875
|
if (flags.csv === true) {
|
|
6958
7876
|
const { bytes, name } = await remoteBytes(url, flags, `/api/billing/statement?month=${month}&format=csv`, `statement ${month}`);
|
|
6959
7877
|
if (typeof flags.file !== "string") {
|
|
6960
|
-
|
|
7878
|
+
writeExact(bytes);
|
|
6961
7879
|
return 0;
|
|
6962
7880
|
}
|
|
6963
7881
|
return writeDownload(bytes, name ?? `foldrun-${month}.csv`, flags, `the ${month} statement`);
|
|
@@ -7058,7 +7976,27 @@ async function billingVerbCmd(positional, flags) {
|
|
|
7058
7976
|
return 0;
|
|
7059
7977
|
}
|
|
7060
7978
|
|
|
7061
|
-
|
|
7979
|
+
if (verb === "credit") {
|
|
7980
|
+
// A manual credit (POST /api/billing): what a self-hosted or dev install
|
|
7981
|
+
// without Stripe uses to put money on the balance. Once billing is live
|
|
7982
|
+
// the platform refuses it — top-ups go through checkout.
|
|
7983
|
+
const amount = Number(sub);
|
|
7984
|
+
if (!(amount > 0) || !Number.isFinite(amount)) throw new Error("`foldrun billing credit <usd> [--note \"…\"]` — a positive amount in USD");
|
|
7985
|
+
let r;
|
|
7986
|
+
try {
|
|
7987
|
+
r = await remoteCall(url, flags, "/api/billing", { method: "POST", body: JSON.stringify({ usd: amount, ...(typeof flags.note === "string" ? { note: flags.note } : {}) }) });
|
|
7988
|
+
} catch (err) {
|
|
7989
|
+
if (err instanceof HttpError && err.status === 403 && /checkout/.test(err.message)) {
|
|
7990
|
+
throw new Error(`billing is live on this platform, so credit is not added by hand — \`foldrun billing top-up ${amount}\` (${err.message})`);
|
|
7991
|
+
}
|
|
7992
|
+
if (err instanceof HttpError && err.status === 403) throw new Error(`billing credit is the account owner's alone (billing:manage) — ${err.message}`);
|
|
7993
|
+
throw err;
|
|
7994
|
+
}
|
|
7995
|
+
const bal = r.balanceUsd;
|
|
7996
|
+
console.log(`\n ${c.green("✓")} credited ${usd(amount)}${bal !== undefined ? c.dim(` · balance ${usd(bal)}`) : ""}${typeof flags.note === "string" ? c.dim(` · "${flags.note}"`) : ""}\n`);
|
|
7997
|
+
return 0;
|
|
7998
|
+
}
|
|
7999
|
+
throw new Error(`unknown billing verb "${verb}" — statement, wallet, details, portal or credit (bare \`foldrun billing\` is the balance)`);
|
|
7062
8000
|
}
|
|
7063
8001
|
|
|
7064
8002
|
/** The defaults a workspace's AGENTS.md sets, read from its source. */
|
|
@@ -7253,6 +8191,49 @@ async function notificationsCmd(positional, flags) {
|
|
|
7253
8191
|
return 0;
|
|
7254
8192
|
}
|
|
7255
8193
|
|
|
8194
|
+
/** Why a flag is what it is, as `foldrun flags` says it. */
|
|
8195
|
+
const FLAG_SOURCES = {
|
|
8196
|
+
account: "set for this account",
|
|
8197
|
+
global: "set for every account",
|
|
8198
|
+
rollout: "staged rollout",
|
|
8199
|
+
default: "default",
|
|
8200
|
+
};
|
|
8201
|
+
|
|
8202
|
+
/**
|
|
8203
|
+
* `foldrun flags` — the feature flags as they stand for this account: on or
|
|
8204
|
+
* off, and what decided it (this account's override, the install-wide
|
|
8205
|
+
* setting, a percentage rollout, or the default). Read-only: only the
|
|
8206
|
+
* platform's super admin changes a flag, in the console, with a session —
|
|
8207
|
+
* an API key never reaches those routes, so the CLI has no verb for them.
|
|
8208
|
+
*/
|
|
8209
|
+
async function flagsCmd(positional, flags) {
|
|
8210
|
+
const url = platformFor(flags, "flags");
|
|
8211
|
+
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");
|
|
8212
|
+
const r = await remoteCall(url, flags, "/api/account/flags");
|
|
8213
|
+
if (flags.json === true) {
|
|
8214
|
+
console.log(JSON.stringify(r, null, 2));
|
|
8215
|
+
return 0;
|
|
8216
|
+
}
|
|
8217
|
+
const list = r.flags ?? [];
|
|
8218
|
+
if (!list.length) {
|
|
8219
|
+
console.log(`\n ${c.dim("no feature flags on this platform")}\n`);
|
|
8220
|
+
return 0;
|
|
8221
|
+
}
|
|
8222
|
+
const w = Math.max(...list.map((f) => f.name.length));
|
|
8223
|
+
const sw = Math.max(...list.map((f) => why(f).length));
|
|
8224
|
+
console.log("");
|
|
8225
|
+
for (const f of list) {
|
|
8226
|
+
const state = f.on ? c.green("on ") : c.dim("off");
|
|
8227
|
+
console.log(` ${pad(f.name, w)} ${state} ${c.dim(pad(why(f), sw))} ${f.description}`);
|
|
8228
|
+
}
|
|
8229
|
+
console.log(`\n ${c.dim("read-only — the platform's super admin turns flags on and off")}\n`);
|
|
8230
|
+
return 0;
|
|
8231
|
+
function why(f) {
|
|
8232
|
+
const base = FLAG_SOURCES[f.source] ?? String(f.source ?? "");
|
|
8233
|
+
return f.source === "rollout" && typeof f.rollout === "number" ? `${base} ${f.rollout}%` : base;
|
|
8234
|
+
}
|
|
8235
|
+
}
|
|
8236
|
+
|
|
7256
8237
|
/**
|
|
7257
8238
|
* `foldrun history [path] --to <workspace>` — every change to a deployed
|
|
7258
8239
|
* workspace, newest first: who, what, which files. `--id <revision>` shows
|
|
@@ -7425,7 +8406,27 @@ async function backupsCmd(positional, flags) {
|
|
|
7425
8406
|
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
8407
|
return 0;
|
|
7427
8408
|
}
|
|
7428
|
-
if (verb
|
|
8409
|
+
if (verb === "requests") {
|
|
8410
|
+
// Every restore this account has asked the platform team for, and where
|
|
8411
|
+
// each stands (GET /api/account/backups/requests) — `backups` shows five.
|
|
8412
|
+
const { requests = [] } = await remoteCall(url, flags, "/api/account/backups/requests");
|
|
8413
|
+
if (flags.json === true) {
|
|
8414
|
+
console.log(JSON.stringify(requests, null, 2));
|
|
8415
|
+
return 0;
|
|
8416
|
+
}
|
|
8417
|
+
if (!requests.length) {
|
|
8418
|
+
console.log(`\n ${c.dim("no restore requests — foldrun backups request --what runs --at \"<when>\" asks for one")}\n`);
|
|
8419
|
+
return 0;
|
|
8420
|
+
}
|
|
8421
|
+
console.log();
|
|
8422
|
+
for (const r of requests) {
|
|
8423
|
+
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"}`)}`);
|
|
8424
|
+
if (r.note) console.log(` ${c.dim(firstLine(r.note, 100))}`);
|
|
8425
|
+
}
|
|
8426
|
+
console.log(`\n ${c.dim(`${requests.length} request${requests.length === 1 ? "" : "s"} · the platform team restores; nothing here changes until they do`)}\n`);
|
|
8427
|
+
return 0;
|
|
8428
|
+
}
|
|
8429
|
+
if (verb !== "ls" && verb !== "list") throw new Error(`unknown backups verb "${verb}" — ls, request, requests`);
|
|
7429
8430
|
const b = await remoteCall(url, flags, "/api/account/backups");
|
|
7430
8431
|
if (flags.json === true) {
|
|
7431
8432
|
console.log(JSON.stringify(b, null, 2));
|
|
@@ -7908,7 +8909,7 @@ async function scheduleTickCmd(flags) {
|
|
|
7908
8909
|
return 0;
|
|
7909
8910
|
}
|
|
7910
8911
|
|
|
7911
|
-
const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge"];
|
|
8912
|
+
const LIBRARY_KINDS_CLI = ["skills", "tools", "hooks", "scripts", "knowledge", "memory"];
|
|
7912
8913
|
|
|
7913
8914
|
/**
|
|
7914
8915
|
* `foldrun library [ls [kind]] | cat <kind>/<path> | put <kind>/<path> | rm
|
|
@@ -7928,20 +8929,27 @@ async function libraryCmd(positional, flags) {
|
|
|
7928
8929
|
if (verb === "ls" || verb === "list") {
|
|
7929
8930
|
const kinds = spec ? [spec] : LIBRARY_KINDS_CLI;
|
|
7930
8931
|
for (const k of kinds) if (!LIBRARY_KINDS_CLI.includes(k)) throw new Error(`no library kind "${k}" — ${LIBRARY_KINDS_CLI.join(", ")}`);
|
|
7931
|
-
|
|
8932
|
+
// Listing everything, a kind an older platform does not have yet (hooks
|
|
8933
|
+
// before 11 Oct 2026) is left out rather than failing the whole list;
|
|
8934
|
+
// asked for by name, it fails with the platform's own answer.
|
|
8935
|
+
const lists = await Promise.all(kinds.map((k) => remoteCall(url, flags, `/api/library/${k}`).catch((err) => {
|
|
8936
|
+
if (spec) throw err;
|
|
8937
|
+
return null;
|
|
8938
|
+
})));
|
|
7932
8939
|
console.log();
|
|
7933
8940
|
lists.forEach((l, n) => {
|
|
8941
|
+
if (!l) return;
|
|
7934
8942
|
const entries = l.entries ?? [];
|
|
7935
8943
|
console.log(` ${c.bold(kinds[n])} ${c.dim(`${entries.length}`)}`);
|
|
7936
8944
|
for (const e of entries) console.log(` ${pad(e.name ?? e.path ?? "", 28)} ${c.dim(firstLine(e.description ?? "", 70))}`);
|
|
7937
8945
|
});
|
|
7938
|
-
console.log(`\n ${c.dim("foldrun library cat <kind>/<path> · put <kind>/<path> (--file, else stdin) · rm <kind>/<path>")}\n`);
|
|
8946
|
+
console.log(`\n ${c.dim("foldrun library cat <kind>/<path> · put <kind>/<path> (--file, else stdin) · new <kind>/<name> · rm <kind>/<path>")}\n`);
|
|
7939
8947
|
return 0;
|
|
7940
8948
|
}
|
|
7941
8949
|
if (verb === "cat") {
|
|
7942
8950
|
const { kind, rel } = split(spec);
|
|
7943
8951
|
const { content } = await remoteCall(url, flags, `/api/library/${kind}?path=${enc(rel)}`);
|
|
7944
|
-
|
|
8952
|
+
writeExact(content ?? "");
|
|
7945
8953
|
return 0;
|
|
7946
8954
|
}
|
|
7947
8955
|
if (verb === "put") {
|
|
@@ -7954,6 +8962,17 @@ async function libraryCmd(positional, flags) {
|
|
|
7954
8962
|
console.log(`\n ${c.green("✓")} library/${kind}/${rel} ${c.dim(`${humanBytes(Buffer.byteLength(content))} — every workspace in the account sees it`)}\n`);
|
|
7955
8963
|
return 0;
|
|
7956
8964
|
}
|
|
8965
|
+
if (verb === "new") {
|
|
8966
|
+
// The Library page's New (POST /api/library/<kind>): the platform writes
|
|
8967
|
+
// the template — a blank one, a tool of a transport, or a gallery entry
|
|
8968
|
+
// under this name (--template).
|
|
8969
|
+
const { kind, rel: name } = split(spec);
|
|
8970
|
+
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`);
|
|
8971
|
+
const body = { name, ...(typeof flags.template === "string" ? { template: flags.template } : {}), ...(typeof flags.language === "string" ? { language: flags.language } : {}) };
|
|
8972
|
+
const r = await remoteCall(url, flags, `/api/library/${kind}`, { method: "POST", body: JSON.stringify(body) });
|
|
8973
|
+
console.log(`\n ${c.green("✓")} library/${kind}/${r.path ?? name} ${c.dim("— every workspace in the account sees it")}\n`);
|
|
8974
|
+
return 0;
|
|
8975
|
+
}
|
|
7957
8976
|
if (verb === "rm") {
|
|
7958
8977
|
const { kind, rel } = split(spec);
|
|
7959
8978
|
if (!(await sureToDelete(flags, "library rm", `library/${kind}/${rel} — every workspace that names it loses it`))) return 1;
|
|
@@ -7961,7 +8980,7 @@ async function libraryCmd(positional, flags) {
|
|
|
7961
8980
|
console.log(`\n ${c.green("✓")} removed library/${kind}/${rel}\n`);
|
|
7962
8981
|
return 0;
|
|
7963
8982
|
}
|
|
7964
|
-
throw new Error(`unknown library verb "${verb}" — ls [kind], cat, put, rm`);
|
|
8983
|
+
throw new Error(`unknown library verb "${verb}" — ls [kind], cat, put, new, rm`);
|
|
7965
8984
|
}
|
|
7966
8985
|
|
|
7967
8986
|
/**
|
|
@@ -8199,10 +9218,29 @@ async function apiVersionCmd(positional, flags) {
|
|
|
8199
9218
|
console.log(`\n ${c.bold("API version")} ${v.effective} ${c.dim(v.pinned ? `pinned (current is ${v.current})` : "current — not pinned")}`);
|
|
8200
9219
|
console.log(` ${c.dim(`served: ${(v.supported ?? []).join(", ") || "—"} · each for ${v.supportMonths ?? "?"} months after the next · this CLI sends ${API_VERSION}`)}`);
|
|
8201
9220
|
if (l.perCredential) console.log(` ${c.bold("rate limits")} ${c.dim(`${l.plan} · per key ${l.perCredential.read} reads / ${l.perCredential.write} writes, per account ${l.perAccount?.read} / ${l.perAccount?.write} — every ${l.windowSeconds}s`)}`);
|
|
9221
|
+
apiUsageLines(facts.usage);
|
|
8202
9222
|
console.log(`\n ${c.dim("foldrun api version pin YYYY-MM-DD · unpin")}\n`);
|
|
8203
9223
|
return 0;
|
|
8204
9224
|
}
|
|
8205
9225
|
|
|
9226
|
+
/** The account's use of the API, last 30 days: requests by version, and any
|
|
9227
|
+
* deprecated route it still calls, by whom, and when it stops. An older
|
|
9228
|
+
* platform sends no `usage`; null means it could not be read. */
|
|
9229
|
+
function apiUsageLines(usage) {
|
|
9230
|
+
if (usage === undefined) return;
|
|
9231
|
+
if (usage === null) {
|
|
9232
|
+
console.log(` ${c.bold("your use")} ${c.dim("could not be read just now")}`);
|
|
9233
|
+
return;
|
|
9234
|
+
}
|
|
9235
|
+
const total = (usage.versions ?? []).reduce((s, v) => s + (v.requests ?? 0), 0);
|
|
9236
|
+
const day = (iso) => String(iso ?? "").slice(0, 10);
|
|
9237
|
+
console.log(` ${c.bold("your use")} ${c.dim(total ? `last ${usage.days} days: ` + usage.versions.map((v) => `${v.version} ${v.requests} (last ${day(v.lastSeen)})`).join(" · ") : `no API requests in the last ${usage.days} days`)}`);
|
|
9238
|
+
for (const d of usage.deprecated ?? []) {
|
|
9239
|
+
console.log(` ${c.amber("deprecated")} ${d.route} — ${d.requests} request${d.requests === 1 ? "" : "s"}, last ${day(d.lastSeen)}; ${d.sunsetAt ? `stops answering ${day(d.sunsetAt)}` : "no end date yet"}${d.link ? ` · ${d.link}` : ""}`);
|
|
9240
|
+
for (const who of d.callers ?? []) console.log(` ${c.dim(`${who.credential || "an unknown caller"}: ${who.requests}, last ${day(who.lastSeen)}`)}`);
|
|
9241
|
+
}
|
|
9242
|
+
}
|
|
9243
|
+
|
|
8206
9244
|
const THEMES_CLI = ["system", "light", "dark"];
|
|
8207
9245
|
|
|
8208
9246
|
/**
|
|
@@ -8230,7 +9268,8 @@ async function preferencesCmd(positional, flags) {
|
|
|
8230
9268
|
return 0;
|
|
8231
9269
|
}
|
|
8232
9270
|
|
|
8233
|
-
/** `foldrun changelog [--limit n]` — What's new:
|
|
9271
|
+
/** `foldrun changelog [--limit n]` — What's new: the API's versions, what each
|
|
9272
|
+
* broke and what is deprecated, then each release and what it changed. */
|
|
8234
9273
|
async function changelogCmd(flags) {
|
|
8235
9274
|
const url = platformFor(flags, "changelog");
|
|
8236
9275
|
const limit = Number(flags.limit) > 0 ? Math.min(50, Math.floor(Number(flags.limit))) : 5;
|
|
@@ -8239,6 +9278,7 @@ async function changelogCmd(flags) {
|
|
|
8239
9278
|
console.log(JSON.stringify(r, null, 2));
|
|
8240
9279
|
return 0;
|
|
8241
9280
|
}
|
|
9281
|
+
if (r.api) apiChangelogLines(r.api);
|
|
8242
9282
|
const releases = r.releases ?? [];
|
|
8243
9283
|
if (!releases.length) {
|
|
8244
9284
|
console.log(`\n ${c.dim(`no release notes on this install (it runs ${r.version ?? "an unreleased build"})`)}\n`);
|
|
@@ -8261,6 +9301,25 @@ async function changelogCmd(flags) {
|
|
|
8261
9301
|
return 0;
|
|
8262
9302
|
}
|
|
8263
9303
|
|
|
9304
|
+
/** The API part of What's new: the current version, the breaking changes by
|
|
9305
|
+
* version, and routes on their way out. Older platforms send no `api`. */
|
|
9306
|
+
function apiChangelogLines(api) {
|
|
9307
|
+
const p = printable;
|
|
9308
|
+
const broke = (api.versions ?? []).filter((v) => v.changes?.length);
|
|
9309
|
+
const deps = api.deprecations ?? [];
|
|
9310
|
+
console.log(`\n ${c.bold(`API ${p(api.current)}`)} ${c.dim(`each version works ${Number(api.supportMonths) || 12} months or more after the next · pin: foldrun api version pin <date>`)}`);
|
|
9311
|
+
if (!broke.length && !deps.length) {
|
|
9312
|
+
console.log(` ${c.dim("nothing existing code would break on, and nothing deprecated")}`);
|
|
9313
|
+
return;
|
|
9314
|
+
}
|
|
9315
|
+
if (broke.length) console.log(` ${c.dim("breaking changes")}`);
|
|
9316
|
+
for (const v of broke) for (const ch of v.changes) console.log(` · ${p(v.version)} ${firstLine(p(ch), 100)}`);
|
|
9317
|
+
if (deps.length) console.log(` ${c.dim("deprecated")}`);
|
|
9318
|
+
for (const d of deps) {
|
|
9319
|
+
console.log(` · ${p(d.route)} — since ${p(d.deprecatedAt)}, ${d.sunsetAt ? `stops answering ${p(d.sunsetAt)}` : "no end date yet"}${d.link ? ` · ${p(d.link)}` : ""}`);
|
|
9320
|
+
}
|
|
9321
|
+
}
|
|
9322
|
+
|
|
8264
9323
|
/**
|
|
8265
9324
|
* `foldrun find <query>` — the ⌘K search: workspaces, agents, flows, tools,
|
|
8266
9325
|
* skills, knowledge, memory and runs across the account, ranked as the
|
|
@@ -8290,6 +9349,95 @@ async function findCmd(positional, flags) {
|
|
|
8290
9349
|
}
|
|
8291
9350
|
|
|
8292
9351
|
/** `foldrun workspaces demo` — "Try the demo pipeline": the demo workspace, made on the platform. */
|
|
9352
|
+
/**
|
|
9353
|
+
* `foldrun workspaces show <name>` — what is deployed in one workspace (GET
|
|
9354
|
+
* /api/workspaces/<name>): its agents, its flows and how they fire, and its
|
|
9355
|
+
* files, counted by top-level folder.
|
|
9356
|
+
*/
|
|
9357
|
+
async function workspacesShowCmd(name, flags) {
|
|
9358
|
+
const url = platformFor(flags, "workspaces show");
|
|
9359
|
+
if (!name) throw new Error("which workspace? `foldrun workspaces show <name>`");
|
|
9360
|
+
const r = await remoteCall(url, flags, `/api/workspaces/${enc(name)}`);
|
|
9361
|
+
if (flags.json === true) {
|
|
9362
|
+
console.log(JSON.stringify(r, null, 2));
|
|
9363
|
+
return 0;
|
|
9364
|
+
}
|
|
9365
|
+
const agents = r.agents ?? [];
|
|
9366
|
+
const flows = r.flows ?? [];
|
|
9367
|
+
const files = r.files ?? [];
|
|
9368
|
+
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"}`)}`);
|
|
9369
|
+
if (agents.length) {
|
|
9370
|
+
const w = Math.max(...agents.map((a) => a.name.length));
|
|
9371
|
+
console.log(`\n ${c.dim("agents")}`);
|
|
9372
|
+
for (const a of agents) console.log(` ${c.bold(pad(a.name, w))} ${pad(a.model ?? "", 8)} ${c.dim(firstLine(a.description ?? "", 70))}`);
|
|
9373
|
+
}
|
|
9374
|
+
if (flows.length) {
|
|
9375
|
+
const w = Math.max(...flows.map((f) => f.name.length));
|
|
9376
|
+
console.log(`\n ${c.dim("flows")}`);
|
|
9377
|
+
for (const f of flows) {
|
|
9378
|
+
const fires = f.trigger === "schedule" && f.schedule ? `schedule ${f.schedule}${f.timezone ? ` ${f.timezone}` : ""}` : (f.trigger ?? "manual");
|
|
9379
|
+
console.log(` ${c.bold(pad(f.name, w))} ${c.dim(fires)}`);
|
|
9380
|
+
}
|
|
9381
|
+
}
|
|
9382
|
+
if (files.length) {
|
|
9383
|
+
const by = new Map();
|
|
9384
|
+
for (const f of files) {
|
|
9385
|
+
const top = f.includes("/") ? `${f.slice(0, f.indexOf("/"))}/` : f;
|
|
9386
|
+
by.set(top, (by.get(top) ?? 0) + 1);
|
|
9387
|
+
}
|
|
9388
|
+
console.log(`\n ${c.dim("files")}`);
|
|
9389
|
+
console.log(` ${[...by].map(([k, n]) => (n > 1 || k.endsWith("/") ? `${k} ${c.dim(String(n))}` : k)).join(" ")}`);
|
|
9390
|
+
}
|
|
9391
|
+
console.log(`\n ${c.dim(`foldrun source ls --to ${r.name ?? name} lists every file · --json for the whole document`)}\n`);
|
|
9392
|
+
return 0;
|
|
9393
|
+
}
|
|
9394
|
+
|
|
9395
|
+
/**
|
|
9396
|
+
* `foldrun eval new <name> --to <workspace>` — the Evals page's New: the
|
|
9397
|
+
* platform writes an eval whose agent is the workspace's first (POST
|
|
9398
|
+
* …/evals). Edit the cases, then `foldrun eval <name> --to` runs it.
|
|
9399
|
+
*/
|
|
9400
|
+
async function evalNewCmd(name, flags, layout) {
|
|
9401
|
+
const url = platformFor(flags, "eval new");
|
|
9402
|
+
const ws = platformWorkspace(flags, layout, `eval new ${name}`);
|
|
9403
|
+
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`);
|
|
9404
|
+
const r = await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/evals`, { method: "POST", body: JSON.stringify({ name }) });
|
|
9405
|
+
console.log(`\n ${c.green("created")} eval ${c.bold(name)} in ${ws} ${c.dim(`on ${url}`)}`);
|
|
9406
|
+
console.log(` ${c.dim(r.path ?? `evals/${name}.md`)}`);
|
|
9407
|
+
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`);
|
|
9408
|
+
return 0;
|
|
9409
|
+
}
|
|
9410
|
+
|
|
9411
|
+
/**
|
|
9412
|
+
* `foldrun team` — who is in the account, their role and which workspaces
|
|
9413
|
+
* they may open (GET /api/team). Read-only on purpose: inviting, changing a
|
|
9414
|
+
* role, removing someone and transferring the account are a person's acts,
|
|
9415
|
+
* and the platform refuses them from a key.
|
|
9416
|
+
*/
|
|
9417
|
+
async function teamCmd(positional, flags) {
|
|
9418
|
+
if (positional[0]) {
|
|
9419
|
+
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");
|
|
9420
|
+
}
|
|
9421
|
+
const url = platformFor(flags, "team");
|
|
9422
|
+
const { members = [] } = await remoteCall(url, flags, "/api/team");
|
|
9423
|
+
if (flags.json === true) {
|
|
9424
|
+
console.log(JSON.stringify(members, null, 2));
|
|
9425
|
+
return 0;
|
|
9426
|
+
}
|
|
9427
|
+
if (!members.length) {
|
|
9428
|
+
console.log(`\n ${c.dim("no members visible to this key")}\n`);
|
|
9429
|
+
return 0;
|
|
9430
|
+
}
|
|
9431
|
+
const w = Math.max(...members.map((m) => m.email.length));
|
|
9432
|
+
console.log();
|
|
9433
|
+
for (const m of members) {
|
|
9434
|
+
const scope = m.workspaces === null || m.workspaces === undefined ? "every workspace" : m.workspaces.length ? m.workspaces.join(", ") : "no workspaces";
|
|
9435
|
+
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)}` : "")}`);
|
|
9436
|
+
}
|
|
9437
|
+
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`);
|
|
9438
|
+
return 0;
|
|
9439
|
+
}
|
|
9440
|
+
|
|
8293
9441
|
async function workspacesDemoCmd(flags) {
|
|
8294
9442
|
const url = platformFor(flags, "workspaces demo");
|
|
8295
9443
|
const r = await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ demo: true }) });
|
|
@@ -8378,7 +9526,7 @@ function printPreview(p, rel) {
|
|
|
8378
9526
|
case "text":
|
|
8379
9527
|
case "html":
|
|
8380
9528
|
case "svg":
|
|
8381
|
-
|
|
9529
|
+
writeExact(`${p.text ?? p.html ?? p.svg ?? ""}`.replace(/\n?$/, "\n"));
|
|
8382
9530
|
if (p.truncated) out(c.dim("… (truncated)"));
|
|
8383
9531
|
return;
|
|
8384
9532
|
case "table":
|
|
@@ -8496,10 +9644,15 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8496
9644
|
: check(workspace, flags);
|
|
8497
9645
|
case "pull":
|
|
8498
9646
|
return pullCmd(layout, flags, positional[0]);
|
|
9647
|
+
case "export":
|
|
9648
|
+
return exportCmd(positional, flags, layout);
|
|
9649
|
+
case "import":
|
|
9650
|
+
return importCmd(positional, flags, layout);
|
|
8499
9651
|
case "status":
|
|
8500
9652
|
return statusCmd(layout, flags, positional[0]);
|
|
8501
9653
|
case "workspaces":
|
|
8502
9654
|
if (positional[0] === "demo") return workspacesDemoCmd(flags);
|
|
9655
|
+
if (positional[0] === "show") return workspacesShowCmd(positional[1], flags);
|
|
8503
9656
|
return workspacesCmd(positional, flags, layout);
|
|
8504
9657
|
case "extract":
|
|
8505
9658
|
return extract(workspace, flags);
|
|
@@ -8509,6 +9662,9 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8509
9662
|
needsOne(layout, "run");
|
|
8510
9663
|
return runTarget(positional[0], flags);
|
|
8511
9664
|
case "eval":
|
|
9665
|
+
// `eval new <name>` makes one on the platform; an eval called "new"
|
|
9666
|
+
// still runs as `foldrun eval new` with nothing after it.
|
|
9667
|
+
if (positional[0] === "new" && positional[1]) return evalNewCmd(positional[1], flags, layout);
|
|
8512
9668
|
// --to names a deployed workspace: its evals run there.
|
|
8513
9669
|
if (typeof flags.to === "string") return remoteEvalsCmd(positional[0], flags, layout);
|
|
8514
9670
|
needsOne(layout, "eval");
|
|
@@ -8528,6 +9684,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8528
9684
|
return webhooksCmd(positional, flags, layout);
|
|
8529
9685
|
case "notifications":
|
|
8530
9686
|
return notificationsCmd(positional, flags);
|
|
9687
|
+
case "flags":
|
|
9688
|
+
return flagsCmd(positional, flags);
|
|
8531
9689
|
case "history":
|
|
8532
9690
|
if (positional[0] === "restore") return historyRestoreCmd(positional, flags, layout);
|
|
8533
9691
|
return historyCmd(positional, flags, layout);
|
|
@@ -8537,6 +9695,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8537
9695
|
return restoreCmd(positional, flags);
|
|
8538
9696
|
case "backups":
|
|
8539
9697
|
return backupsCmd(positional, flags);
|
|
9698
|
+
case "team":
|
|
9699
|
+
return teamCmd(positional, flags);
|
|
8540
9700
|
case "onboarding":
|
|
8541
9701
|
return onboardingCmd(flags);
|
|
8542
9702
|
case "probe":
|
|
@@ -8609,6 +9769,8 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8609
9769
|
// `new` scaffolds one here; `run` runs one there. The noun is the same
|
|
8610
9770
|
// thing in both cases, which is why they share a command.
|
|
8611
9771
|
if (positional[0] === "link" || positional[0] === "unlink") return agentLinkCmd(positional, flags, layout);
|
|
9772
|
+
if (positional[0] === "import") return agentImportCmd(positional, flags, layout);
|
|
9773
|
+
if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("agents", flags, layout);
|
|
8612
9774
|
return positional[0] === "run"
|
|
8613
9775
|
? agentRunCmd(positional, flags, layout)
|
|
8614
9776
|
: scaffoldCmd("agents", positional, flags, layout);
|
|
@@ -8621,17 +9783,22 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
8621
9783
|
if (positional[0] === "show") return flowShowCmd(positional, flags, layout);
|
|
8622
9784
|
if (positional[0] === "run") return invoke(positional[1], flags);
|
|
8623
9785
|
if (positional[0] === "rotate-hook") return rotateHookCmd(positional, flags, layout);
|
|
9786
|
+
if (positional[0] === "hook") return flowHookCmd(positional, flags, layout);
|
|
8624
9787
|
if (positional[0] === "set") return flowSetCmd(positional, flags, layout);
|
|
8625
9788
|
if (positional[0] === "trigger") return flowTriggerCmd(positional, flags, layout);
|
|
8626
9789
|
if (positional[0] === "move-step") return flowMoveStepCmd(positional, flags, layout);
|
|
8627
9790
|
if (positional[0] === "copy-step") return flowCopyStepCmd(positional, flags, layout);
|
|
8628
9791
|
if (positional[0] === "paste") return flowPasteCmd(positional, flags, layout);
|
|
8629
9792
|
if (positional[0] === "draft") return flowDraftCmd(positional, flags, layout);
|
|
9793
|
+
if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("flows", flags, layout);
|
|
8630
9794
|
return scaffoldCmd("flows", positional, flags, layout);
|
|
8631
9795
|
case "tool":
|
|
8632
9796
|
return positional[0] === "test"
|
|
8633
9797
|
? toolTestCmd(positional, flags, layout)
|
|
8634
9798
|
: scaffoldCmd("tools", positional, flags, layout);
|
|
9799
|
+
case "hook":
|
|
9800
|
+
case "hooks":
|
|
9801
|
+
return hookCmd(positional, flags, layout);
|
|
8635
9802
|
case "invoke":
|
|
8636
9803
|
return invoke(positional[0], flags);
|
|
8637
9804
|
case "source":
|