foldrun 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1780 @@
1
+ // CLI commands. Kept separate from bin/ so the environment is set before the
2
+ // core is imported — single-workspace mode is read at module load.
3
+
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { credentialFor, defaultPlatform, saveCredential, removeCredential, readCredentials, normaliseUrl } from "./credentials.mjs";
8
+
9
+ const c = {
10
+ dim: (s) => `\x1b[2m${s}\x1b[0m`,
11
+ bold: (s) => `\x1b[1m${s}\x1b[0m`,
12
+ green: (s) => `\x1b[32m${s}\x1b[0m`,
13
+ red: (s) => `\x1b[31m${s}\x1b[0m`,
14
+ yellow: (s) => `\x1b[33m${s}\x1b[0m`,
15
+ amber: (s) => `\x1b[33m${s}\x1b[0m`,
16
+ };
17
+
18
+ // The published runtime, not a relative reach into a sibling directory. It was
19
+ // `../../core/index.ts` — which works only inside this repo, so `npx foldrun`
20
+ // installed a CLI that could not find its own runtime. Imported lazily so
21
+ // `--help` and argument errors never pay for loading it.
22
+ const core = async () => import("@foldrun/core");
23
+
24
+ // ---------------------------------------------------------------- init
25
+
26
+ /**
27
+ * Read a template directory as the same {path, content} list starterFiles
28
+ * returns, so both sources of a new workspace go through one writer.
29
+ *
30
+ * Run artifacts are skipped: a template is what someone authored, and copying
31
+ * a previous run's outputs or journal into a fresh workspace hands it a
32
+ * history it never had.
33
+ */
34
+ function templateFilesFrom(dir) {
35
+ const skip = new Set(["runs", "outputs", ".foldrun", "node_modules", ".git"]);
36
+ const out = [];
37
+ const walk = (abs, rel) => {
38
+ for (const entry of fs.readdirSync(abs).sort()) {
39
+ if (skip.has(entry)) continue;
40
+ const full = path.join(abs, entry);
41
+ const next = rel ? `${rel}/${entry}` : entry;
42
+ if (fs.statSync(full).isDirectory()) walk(full, next);
43
+ else out.push({ path: next, content: fs.readFileSync(full, "utf8") });
44
+ }
45
+ };
46
+ walk(path.resolve(dir), "");
47
+ return out;
48
+ }
49
+
50
+ async function init(workspace, from) {
51
+ // The same definition the dashboard's "+ New workspace" uses — see
52
+ // core/src/starter.ts for why it is not two lists.
53
+ const { starterFiles, syncWorkspaceBundles, ensureAccountFiles } = await core();
54
+
55
+ // A template is a source, a workspace is a destination. Keeping the two
56
+ // words apart is the whole reason `templates/` is not called `examples/`:
57
+ // there is one place a workspace lives, and it is wherever you make one.
58
+ if (from && !fs.existsSync(from)) {
59
+ throw new Error(`no template at ${from} — pass a directory, e.g. --from templates/hello`);
60
+ }
61
+ const files = from
62
+ ? templateFilesFrom(from)
63
+ : starterFiles(path.basename(path.resolve(workspace)));
64
+
65
+ // Whatever the source, the new workspace must ignore the key that decrypts
66
+ // its secrets. A template does not carry one — it is a source, not a
67
+ // repository — so copying a template verbatim would hand back the very hole
68
+ // the starter's .gitignore exists to close.
69
+ if (!files.some((f) => f.path === ".gitignore")) {
70
+ const guard = starterFiles("x").find((f) => f.path === ".gitignore");
71
+ if (guard) files.unshift(guard);
72
+ }
73
+
74
+ if (fs.existsSync(workspace) && fs.readdirSync(workspace).length > 0) {
75
+ const clashes = files.filter((f) => fs.existsSync(path.join(workspace, f.path)));
76
+ if (clashes.length) {
77
+ throw new Error(`${workspace} already has ${clashes[0].path} — refusing to overwrite`);
78
+ }
79
+ }
80
+ for (const { path: rel, content } of files) {
81
+ const file = path.join(workspace, rel);
82
+ fs.mkdirSync(path.dirname(file), { recursive: true });
83
+ fs.writeFileSync(file, content);
84
+ }
85
+ // knowledge/ and memory/ are OKF bundles, and a bundle without its root
86
+ // index.md declares no okf_version — so `foldrun init` produced a directory
87
+ // of valid concepts that no consumer could tell the version of.
88
+ syncWorkspaceBundles(workspace);
89
+
90
+ // The account scope, one directory up — the same place libraryDir points on
91
+ // a laptop, so `my-desk/` ends up beside the `AGENTS.md` and `library/` that
92
+ // cover it. accountDir cannot answer here: nothing has pinned this process
93
+ // to the new workspace yet, so it is passed explicitly. Listed below with a
94
+ // `../` prefix because init writing outside its target should be visible,
95
+ // not discovered later.
96
+ const account = path.resolve(workspace, "..");
97
+ const accountWritten = ensureAccountFiles("default", account).map((rel) => `../${rel}`);
98
+
99
+ console.log(`\n ${c.green("created")} ${workspace}\n`);
100
+ for (const { path: rel } of files) console.log(` ${c.dim(rel)}`);
101
+ // Not part of the workspace: say so on the line, not in a comment nobody
102
+ // reads. A developer who ran `foldrun init ~/projects/desk` finds an
103
+ // AGENTS.md in ~/projects and should know it was this, and why.
104
+ for (const rel of accountWritten) {
105
+ console.log(` ${c.dim(rel)} ${c.dim("← account scope, shared by every workspace beside this one")}`);
106
+ }
107
+ const flow = files
108
+ .map((f) => f.path.match(/^flows\/(.+)\.md$/)?.[1])
109
+ .find(Boolean);
110
+ console.log(`
111
+ ${c.bold("Next")}
112
+ foldrun check ${workspace}${" ".repeat(Math.max(1, 16 - workspace.length))}${c.dim("validate it — costs nothing")}
113
+ foldrun run ${flow ?? "publish"} --workspace ${workspace} ${c.dim("run the flow")}
114
+ `);
115
+ return 0;
116
+ }
117
+
118
+ // ---------------------------------------------------------------- check
119
+
120
+ // The Agent Skills spec constrains the `name` field and requires a non-empty
121
+ // `description`. Validation is deliberately lenient — the client guide says to
122
+ // warn and load rather than reject, so cross-client skills still run — so these
123
+ // are warnings and one error (an empty description cannot be disclosed, so the
124
+ // runtime skips that skill; the error says why it vanished).
125
+ const SKILL_NAME_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
126
+
127
+ function readFm(file) {
128
+ const block = readFrontmatter(file);
129
+ if (block === null) return null;
130
+ const field = (k) => {
131
+ const m = new RegExp(`^${k}:\\s*(.+)$`, "m").exec(block);
132
+ return m ? m[1].trim().replace(/^["']|["']$/g, "") : null;
133
+ };
134
+ return { name: field("name"), description: field("description") };
135
+ }
136
+
137
+ // Every skill root the runtime scans: each agent's own skills/, the workspace
138
+ // skills/, and the cross-client .agents/skills/ convention.
139
+ function skillRoots(workspace) {
140
+ const roots = [];
141
+ for (const agent of ls(path.join(workspace, "agents"))) {
142
+ roots.push(`agents/${agent}/skills`);
143
+ }
144
+ roots.push("skills", ".agents/skills");
145
+ return roots;
146
+ }
147
+
148
+ function validateSkills(workspace, note) {
149
+ for (const root of skillRoots(workspace)) {
150
+ for (const folder of ls(path.join(workspace, root))) {
151
+ const dir = path.join(workspace, root, folder);
152
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) continue;
153
+ const skillMd = path.join(dir, "SKILL.md");
154
+ if (!fs.existsSync(skillMd)) continue;
155
+ const where = `${root}/${folder}/SKILL.md`;
156
+ const fm = readFm(skillMd);
157
+ if (!fm) { note("warn", where, "no frontmatter — needs name and description"); continue; }
158
+
159
+ if (!fm.description) {
160
+ note("error", where, "no description — the runtime skips a skill it cannot disclose");
161
+ } else if (fm.description.length > 1024) {
162
+ note("warn", where, `description is ${fm.description.length} chars — the spec limit is 1024`);
163
+ }
164
+
165
+ const name = fm.name;
166
+ if (!name) {
167
+ note("warn", where, "no name — the folder name is used, but declare it");
168
+ } else {
169
+ if (name !== folder) {
170
+ note("warn", where, `name "${name}" does not match its folder "${folder}" — the spec requires they match`);
171
+ }
172
+ if (name.length > 64) note("warn", where, `name is ${name.length} chars — the spec limit is 64`);
173
+ if (!SKILL_NAME_RE.test(name)) {
174
+ note("warn", where, `name "${name}" is not lowercase-alphanumeric-with-single-hyphens`);
175
+ }
176
+ }
177
+ }
178
+ }
179
+ }
180
+
181
+ // Single-file subagents authored by ANY coding tool. The vendor-neutral
182
+ // cross-client location is .agents/agents/<name>.md (scanned first); a tool's
183
+ // own dir (.claude/agents/ so far) follows for pragmatic compatibility.
184
+ // readTree maps these into agents/<name>/agent.md at deploy; check mirrors it
185
+ // so the local, pre-deploy experience matches — otherwise a workspace whose
186
+ // only agents were authored elsewhere reports "no agents" until it deploys.
187
+ function importedAgentNames(workspace, nativeNames) {
188
+ const names = new Set();
189
+ for (const dir of [".agents/agents", ".claude/agents"]) {
190
+ for (const entry of ls(path.join(workspace, dir))) {
191
+ if (!entry.endsWith(".md")) continue;
192
+ // A real file, not a directory named x.md — deploy's readTree reads the
193
+ // file and would skip a directory, so check must agree or it counts an
194
+ // agent that never ships.
195
+ try {
196
+ if (!fs.statSync(path.join(workspace, dir, entry)).isFile()) continue;
197
+ } catch {
198
+ continue;
199
+ }
200
+ const name = entry.replace(/\.md$/, "");
201
+ if (nativeNames.has(name)) continue; // native wins
202
+ names.add(name);
203
+ }
204
+ }
205
+ return names;
206
+ }
207
+
208
+ // ---------------------------------------------------------------- extract
209
+
210
+ /**
211
+ * Lift a single-file script tool's program out of its markdown and into a
212
+ * file beside it: `tools/x.md` becomes `tools/x/tool.md` + `tools/x/run.py`.
213
+ *
214
+ * The tool's NAME does not change, which is the property that makes this
215
+ * safe to run against a live workspace: `tools: [x]` in an agent still names
216
+ * the same tool, so no agent, flow or schedule has to be edited alongside.
217
+ * Only where the bytes live changes.
218
+ *
219
+ * Written to be re-runnable. A tool already in folder form, or one with a
220
+ * `run:`, is skipped rather than touched, so a half-finished migration is
221
+ * finished by running it again rather than by unpicking it.
222
+ *
223
+ * Order matters on a live box: the folder is written and re-parsed FIRST,
224
+ * and the flat file is removed only once the result loads as a script tool
225
+ * whose program is on disk. A crash between the two leaves the old file
226
+ * intact and a folder beside it — visible, and fixed by re-running.
227
+ */
228
+ async function extract(workspace, flags) {
229
+ const { fencedCodeBlock, parseToolDef } = await core();
230
+ const dry = Boolean(flags["dry-run"]);
231
+ const dir = path.join(workspace, "tools");
232
+
233
+ if (!fs.existsSync(dir)) {
234
+ console.log(`\n no tools/ in ${workspace} — nothing to extract\n`);
235
+ return 0;
236
+ }
237
+
238
+ const done = [];
239
+ const skipped = [];
240
+ const failed = [];
241
+
242
+ for (const entry of fs.readdirSync(dir).sort()) {
243
+ if (!entry.endsWith(".md")) continue;
244
+ const name = entry.replace(/\.md$/, "");
245
+ const flat = path.join(dir, entry);
246
+ const raw = fs.readFileSync(flat, "utf8");
247
+
248
+ // Frontmatter is edited as text, never re-serialised. A YAML round-trip
249
+ // reorders keys, drops comments and rewrites quoting — a diff nobody
250
+ // asked for across every tool on the box, hiding the one line that
251
+ // actually changed.
252
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(raw);
253
+ if (!fm) {
254
+ skipped.push([name, "no frontmatter"]);
255
+ continue;
256
+ }
257
+ const front = fm[1];
258
+ const body = raw.slice(fm[0].length);
259
+
260
+ if (/^transport:\s*script\s*$/m.test(front) === false && /^run:/m.test(front) === false) {
261
+ skipped.push([name, "not a script tool"]);
262
+ continue;
263
+ }
264
+ if (/^run:/m.test(front)) {
265
+ skipped.push([name, "already points at a file"]);
266
+ continue;
267
+ }
268
+
269
+ const block = fencedCodeBlock(body);
270
+ if (!block) {
271
+ failed.push([name, "transport: script with no run: and no fenced program — nothing to extract"]);
272
+ continue;
273
+ }
274
+
275
+ const program = `run${block.ext}`;
276
+ const folder = path.join(dir, name);
277
+ if (fs.existsSync(folder)) {
278
+ failed.push([name, `tools/${name}/ already exists — resolve by hand`]);
279
+ continue;
280
+ }
281
+
282
+ // The body keeps its prose and loses the block that is now a file. The
283
+ // pointer replaces it so the document still says where the program is.
284
+ const trimmed =
285
+ body.slice(0, block.start).replace(/\n{3,}$/, "\n\n") +
286
+ `\`${program}\` beside this file is the program.\n` +
287
+ body.slice(block.end).replace(/^\n+/, "\n");
288
+
289
+ const manifest =
290
+ `---\n${front.replace(/(^name:.*$)/m, `$1\nrun: ${program}`)}\n---\n\n` + trimmed.replace(/^\n+/, "");
291
+
292
+ if (dry) {
293
+ done.push([name, `${program} (${block.code.split("\n").length} lines)`]);
294
+ continue;
295
+ }
296
+
297
+ fs.mkdirSync(folder, { recursive: true });
298
+ fs.writeFileSync(path.join(folder, program), block.code);
299
+ fs.writeFileSync(path.join(folder, "tool.md"), manifest);
300
+
301
+ // Prove it before deleting anything. parseToolDef is what the runtime
302
+ // uses, so "it loads" here means it loads there.
303
+ const check = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(manifest);
304
+ const data = Object.fromEntries(
305
+ check[1]
306
+ .split("\n")
307
+ .map((l) => /^([a-z_-]+):\s*(.*)$/.exec(l))
308
+ .filter(Boolean)
309
+ .map((m) => [m[1], m[2]]),
310
+ );
311
+ const def = parseToolDef(data, name, manifest.slice(check[0].length));
312
+ const ok =
313
+ def?.kind === "script" &&
314
+ def.spec.run === program &&
315
+ fs.existsSync(path.join(folder, program));
316
+ if (!ok) {
317
+ failed.push([name, "the extracted folder did not parse back as a script tool — flat file left in place"]);
318
+ continue;
319
+ }
320
+
321
+ fs.rmSync(flat);
322
+ done.push([name, `${program} (${block.code.split("\n").length} lines)`]);
323
+ }
324
+
325
+ const label = dry ? "would extract" : "extracted";
326
+ console.log("");
327
+ for (const [name, what] of done) console.log(` ${c.green("✓")} ${label} ${c.bold(name)} → tools/${name}/${what}`);
328
+ for (const [name, why] of skipped) console.log(` ${c.dim("·")} ${c.dim(`${name} — ${why}`)}`);
329
+ for (const [name, why] of failed) console.log(` ${c.red("!")} ${c.bold(name)} — ${why}`);
330
+ console.log(
331
+ `\n ${done.length} ${label}, ${skipped.length} skipped, ${failed.length} failed` +
332
+ (dry ? ` ${c.dim("(--dry-run: nothing written)")}` : "") +
333
+ `\n\n ${c.dim("next: foldrun check " + workspace)}\n`,
334
+ );
335
+ return failed.length ? 1 : 0;
336
+ }
337
+
338
+ /**
339
+ * The library a signed-in developer's workspace will actually resolve
340
+ * against lives on the platform, not on this laptop. Without this, check
341
+ * reported `tools: [site_repo]` missing for a tool that runs fine on every
342
+ * deploy — the first red result a developer saw on a working desk. Read only
343
+ * the names; an unreachable platform is a warning, not a failed check.
344
+ */
345
+ async function platformLibrary(flags) {
346
+ const url = flags.local === true ? undefined : remoteUrl(flags);
347
+ if (!url) return { url: undefined, tools: new Set(), skills: new Set(), warning: null };
348
+ let token;
349
+ try {
350
+ token = tokenFor(url, flags);
351
+ } catch {
352
+ return { url, tools: new Set(), skills: new Set(), warning: `${url} is the platform but this machine is not signed in — library tools there cannot be seen` };
353
+ }
354
+ const names = async (kind) => {
355
+ const res = await fetch(new URL(`/api/library/${kind}`, url), {
356
+ headers: { authorization: `Bearer ${token}` },
357
+ signal: AbortSignal.timeout(8_000),
358
+ });
359
+ if (!res.ok) throw new Error(`/api/library/${kind} → HTTP ${res.status}`);
360
+ const body = await res.json();
361
+ return new Set((body.entries ?? []).map((e) => e.name).filter(Boolean));
362
+ };
363
+ try {
364
+ const [tools, skills] = await Promise.all([names("tools"), names("skills")]);
365
+ return { url, tools, skills, warning: null };
366
+ } catch (err) {
367
+ 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` };
368
+ }
369
+ }
370
+
371
+ async function check(workspace, flags = {}) {
372
+ const {
373
+ listAgents, listFlows, readBundle, conformanceIssues, dateIssues, listEvals, lintFlow,
374
+ workspaceTools, libraryTools, checkFormatVersion, missingToolPrograms, discoverSkills,
375
+ libraryDir,
376
+ } = await core();
377
+ const T = "default";
378
+ const P = "workspace";
379
+ const problems = [];
380
+ // file:line, like every other linter — so an editor can jump to it and CI
381
+ // can annotate the right row.
382
+ const note = (level, where, message, line) =>
383
+ problems.push({ level, where: line ? `${where}:${line}` : where, message });
384
+
385
+ const agents = listAgents(T, P);
386
+ const flows = listFlows(T, P);
387
+ const evals = listEvals(T, P);
388
+ const tools = workspaceTools(T, P);
389
+ // What `tools:` can actually name of your own. The runtime resolves nearest-wins across
390
+ // both scopes, so a checker that only looked at the workspace called a
391
+ // working agent broken — an error, in CI, for an account library tool that
392
+ // runs fine. Mirror the runtime exactly; the summary still counts what this
393
+ // workspace itself defines.
394
+ const usable = { ...libraryTools(T), ...tools };
395
+ const platform = await platformLibrary(flags);
396
+ if (platform.warning) note("warn", "library", platform.warning);
397
+ const agentNames = new Set(agents.map((a) => a.name));
398
+ const imported = importedAgentNames(workspace, agentNames);
399
+ for (const n of imported) agentNames.add(n);
400
+ const flowNames = new Set(flows.map((f) => f.name));
401
+ // Every skill name in scope, found the way the runtime finds them — the
402
+ // agent's own, the workspace's, the cross-client .agents/skills/, and the
403
+ // account library. Discovery comes from core so this cannot drift from
404
+ // what a run actually loads.
405
+ const skillNames = new Set();
406
+ for (const base of [workspace, libraryDir(T)]) {
407
+ for (const sub of ["skills", ".agents/skills"]) {
408
+ for (const s of discoverSkills(base, sub)) skillNames.add(s.name);
409
+ }
410
+ }
411
+ for (const a of agents) {
412
+ for (const s of discoverSkills(path.join(workspace, "agents", a.name))) skillNames.add(s.name);
413
+ }
414
+ for (const s of platform.skills) skillNames.add(s);
415
+
416
+ if (agentNames.size === 0) note("error", "agents/", "no agents — a workspace needs at least one");
417
+
418
+ // What format does this workspace target?
419
+ const agentsMd = path.join(workspace, "AGENTS.md");
420
+ if (fs.existsSync(agentsMd)) {
421
+ const m = fs.readFileSync(agentsMd, "utf8").match(/^foldrun_version:\s*["']?([\d.]+)/m);
422
+ const { warning } = checkFormatVersion(m?.[1]);
423
+ if (warning) note("warn", "AGENTS.md", warning);
424
+ }
425
+
426
+ for (const a of agents) {
427
+ if (!a.description) note("warn", `agents/${a.name}`, "no description — other agents and people read it");
428
+
429
+ // What the author wrote that the runtime already writes, or writes better.
430
+ // Every trap here was one somebody hit while the answer sat in the source:
431
+ // check reads the whole folder anyway, so it may as well teach.
432
+ try {
433
+ const body = fs.readFileSync(path.join(workspace, "agents", a.name, "agent.md"), "utf8");
434
+
435
+ // The runtime appends a "# Where you are" section to every prompt saying
436
+ // the working directory is agents/<name>/ and ../../ is the workspace
437
+ // root. An agent that says it again spends its opening paragraph on
438
+ // something the platform guarantees.
439
+ if (/your working directory is|two levels up|\.\.\/\.\.\/` is the workspace/i.test(body)) {
440
+ note(
441
+ "warn",
442
+ `agents/${a.name}`,
443
+ 'explains where it is — the runtime already appends a "Where you are" section saying this; the paragraph is safe to delete',
444
+ );
445
+ }
446
+
447
+ // `[[link]]` what you READ, spell out what you WRITE. A path that exists
448
+ // is something to read, and a link to it cannot rot when the file is
449
+ // renamed; a path that does not exist yet is an output destination and
450
+ // is correctly literal. So the test is simply whether the file is there.
451
+ const said = new Set(); // one path, one lesson, however often it appears
452
+ for (const m of body.matchAll(/\.\.\/\.\.\/(state|knowledge|memory|storage)\/([^\s`'")]+)/g)) {
453
+ const rel = `${m[1]}/${m[2]}`;
454
+ if (said.has(rel)) continue;
455
+ said.add(rel);
456
+ if (!fs.existsSync(path.join(workspace, rel))) continue; // a destination, not a reference
457
+ const bare = path.basename(m[2]).replace(/\.md$/, "");
458
+ note("warn", `agents/${a.name}`, `reads \`../../${rel}\` — \`[[${bare}]]\` resolves to it and survives a rename`);
459
+ }
460
+ } catch {
461
+ // an unreadable agent.md is already reported by the loader
462
+ }
463
+
464
+ for (const t of a.ownTools) {
465
+ if (usable[t]) continue;
466
+ if (platform.tools.has(t)) {
467
+ // Resolves on the platform's library and nowhere on this machine:
468
+ // fine at run time, worth one line so nobody looks for the file.
469
+ note("info", `agents/${a.name}`, `tools: [${t}] — from the library on ${platform.url}`);
470
+ continue;
471
+ }
472
+ const hint = platform.url
473
+ ? `in this workspace, the local account library, or the library on ${platform.url}`
474
+ : "in this workspace or the account library (a library on a platform is seen when signed in: `foldrun login`, or --url)";
475
+ note("error", `agents/${a.name}`, `tools: [${t}] — no tools/${t}/tool.md or tools/${t}.md ${hint}`);
476
+ }
477
+ // A colleague that does not exist is not a consult tool the agent is
478
+ // missing — it is one it will never be told about, on a run that looks
479
+ // fine. Same shape as a tool that names nothing.
480
+ for (const c of a.consults) {
481
+ if (!agentNames.has(c)) {
482
+ note("error", `agents/${a.name}`, `agents: [${c}] — no such agent in this workspace`);
483
+ } else if (c === a.name) {
484
+ note("warn", `agents/${a.name}`, `agents: [${c}] — an agent consulting itself; the call is refused at run time`);
485
+ }
486
+ }
487
+
488
+ // `skills:` present is an allowlist. A name that matches nothing silently
489
+ // withholds a skill the author believed was loaded — and an empty list
490
+ // withholds every one of them, which is legal but worth saying out loud.
491
+ if (a.skills !== null) {
492
+ if (a.skills.length === 0) {
493
+ note("warn", `agents/${a.name}`, "skills: [] withholds every skill — omit the field to inherit them");
494
+ }
495
+ for (const s of a.skills) {
496
+ if (!skillNames.has(s)) {
497
+ note("error", `agents/${a.name}`, `skills: [${s}] — no skill of that name in this agent, the workspace or the library`);
498
+ }
499
+ }
500
+ }
501
+
502
+ // `use:` is gone. Nothing under it is granted, so say the exact line to
503
+ // write rather than letting the run discover the missing tool.
504
+ if (a.legacyUse.length) {
505
+ note(
506
+ "error",
507
+ `agents/${a.name}`,
508
+ `\`use: [${a.legacyUse.join(", ")}]\` is no longer read — move these into \`tools:\` (node scripts/migrate-use-to-tools.mjs . rewrites every agent)`,
509
+ );
510
+ }
511
+ }
512
+
513
+ // A script tool whose `run:` resolves to nothing parses, counts, and is
514
+ // offered to the agent — then fails inside a turn. Checking it here is the
515
+ // difference between a typo found in CI and a flow that quietly stops
516
+ // using one of its tools. Resolution comes from core, so this agrees with
517
+ // the runner by construction rather than by maintenance.
518
+ for (const m of missingToolPrograms(T, P)) {
519
+ note(
520
+ "error",
521
+ m.scope === "account" ? `library/tools/${m.name}` : `tools/${m.name}`,
522
+ `run: ${m.run} — no such file (looked for ${m.looked})`,
523
+ );
524
+ }
525
+
526
+ validateSkills(workspace, note);
527
+
528
+ for (const f of flows) {
529
+ if (f.steps.length === 0) note("error", `flows/${f.file}`, "no steps");
530
+ for (const s of f.steps) {
531
+ const target = s.subflow ?? s.agent;
532
+ const known = s.subflow ? flowNames.has(target) : agentNames.has(target);
533
+ if (!known) {
534
+ note("error", `flows/${f.file}`, `[[${s.subflow ? "flow:" : ""}${target}]] does not exist`, s.line);
535
+ }
536
+ }
537
+ for (const w of lintFlow(f, { agents: [...agentNames] })) note("warn", `flows/${f.file}`, w.message, w.line);
538
+ }
539
+
540
+ for (const e of evals) {
541
+ const target = e.flow ?? e.agent;
542
+ if (!target) note("error", `evals/${e.file}`, "names neither an agent nor a flow");
543
+ else if (!(e.flow ? flowNames.has(target) : agentNames.has(target))) {
544
+ note("error", `evals/${e.file}`, `${e.flow ? "flow" : "agent"} "${target}" does not exist`);
545
+ }
546
+ if (e.cases.length === 0) note("warn", `evals/${e.file}`, "no cases");
547
+ }
548
+
549
+ // A document's kind is its path, so nothing here declares one. Two older
550
+ // spellings may still be sitting in files and both are dead weight rather
551
+ // than errors: `kind: Agent` (ours, now redundant) and `type: Agent` (ours,
552
+ // back when it lived in OKF's field). The second is worth naming — an OKF
553
+ // consumer reading this repo would file that agent as a knowledge concept.
554
+ for (const [rel, noun] of documentTypes(workspace)) {
555
+ const front = readFrontmatter(path.join(workspace, rel));
556
+ if (front === null) continue;
557
+ const asType = /^type:\s*(.+)$/m.exec(front)?.[1].trim();
558
+ const asKind = /^kind:\s*(.+)$/m.exec(front)?.[1].trim();
559
+
560
+ if (asKind === noun) {
561
+ note("warn", rel, `\`kind: ${noun}\` is no longer read — the path says it; safe to delete`);
562
+ }
563
+ if (asType === noun) {
564
+ note("warn", rel, `\`type: ${noun}\` is OKF's field — delete it, the path says what this is`);
565
+ } else if (asType && rel.startsWith("tools/") && TRANSPORTS.has(asType.toLowerCase())) {
566
+ note("warn", rel, `\`type: ${asType}\` is the old spelling — use \`transport: ${asType}\``);
567
+ }
568
+ }
569
+
570
+ // Knowledge and memory are OKF bundles. The conformance rule lives in
571
+ // conformanceIssues() rather than here: this was a second copy of it, and it
572
+ // asked readBundle — which hides the files we present as indexes — so it
573
+ // agreed the bundle was fine while an outside validator would not.
574
+ for (const kind of ["knowledge", "memory"]) {
575
+ for (const dir of bundleDirs(workspace, kind)) {
576
+ const where = path.relative(workspace, dir);
577
+ for (const { file, issue } of conformanceIssues(dir)) {
578
+ note("error", `${where}/${file}`, issue);
579
+ }
580
+ // A warning, not an error: the bundle is still conformant — the spec says
581
+ // nothing about a date's shape — but the value cannot be compared, so
582
+ // staleness and "most recently verified" would quietly use it wrong.
583
+ for (const { file, field, value } of dateIssues(dir)) {
584
+ note(
585
+ "warn",
586
+ `${where}/${file}`,
587
+ `${field}: "${value}" is not a date — use YYYY-MM-DD or an ISO 8601 datetime. ` +
588
+ `It has been ignored rather than compared.`,
589
+ );
590
+ }
591
+ for (const doc of readBundle(dir)) {
592
+ if (doc.stale) {
593
+ note("warn", `${where}/${doc.file}`, `stale since ${doc.staleAfter}`);
594
+ }
595
+ }
596
+ }
597
+ }
598
+
599
+ const errors = problems.filter((p) => p.level === "error");
600
+ const warnings = problems.filter((p) => p.level === "warn");
601
+
602
+ console.log("");
603
+ for (const p of problems) {
604
+ const tag = p.level === "error" ? c.red("error") : p.level === "warn" ? c.amber(" warn") : c.dim(" info");
605
+ console.log(` ${tag} ${c.bold(p.where)} ${p.message}`);
606
+ }
607
+ const summary = `${agentNames.size} agents · ${flows.length} flows · ${evals.length} evals · ${Object.keys(tools).length} tools`;
608
+ console.log(
609
+ errors.length === 0 && warnings.length === 0
610
+ ? ` ${c.green("✓")} ${summary} — no problems\n`
611
+ : `\n ${summary} · ${errors.length} error${errors.length === 1 ? "" : "s"}, ${warnings.length} warning${warnings.length === 1 ? "" : "s"}\n`,
612
+ );
613
+ return errors.length ? 1 : 0;
614
+ }
615
+
616
+ /** Transports a pre-v0.1 tool could put in `type:`. */
617
+ const TRANSPORTS = new Set(["http", "script", "mcp"]);
618
+
619
+ /** Frontmatter block of a file, or null if it has none. */
620
+ function readFrontmatter(file) {
621
+ if (!fs.existsSync(file)) return null;
622
+ return /^---\r?\n([\s\S]*?)\r?\n---/.exec(fs.readFileSync(file, "utf8"))?.[1] ?? null;
623
+ }
624
+
625
+ /**
626
+ * Every document in the workspace paired with the `type:` it should declare.
627
+ * Mirrors KINDS — the CLI can't import the TypeScript core, so this is the one
628
+ * place the table is restated, and SPEC.md is the contract between them.
629
+ */
630
+ /** Every structural document, paired with the noun it *is* — used only to
631
+ * recognise a leftover declaration of it, never to require one. */
632
+ function documentTypes(workspace) {
633
+ const out = [];
634
+ const add = (rel, type) => fs.existsSync(path.join(workspace, rel)) && out.push([rel, type]);
635
+
636
+ for (const agent of ls(path.join(workspace, "agents"))) {
637
+ add(`agents/${agent}/agent.md`, "Agent");
638
+ for (const skill of ls(path.join(workspace, `agents/${agent}/skills`))) {
639
+ add(`agents/${agent}/skills/${skill}/SKILL.md`, "Skill");
640
+ }
641
+ }
642
+ for (const [dir, type] of [["flows", "Flow"], ["evals", "Eval"], ["tools", "Tool"]]) {
643
+ for (const f of ls(path.join(workspace, dir))) if (f.endsWith(".md")) add(`${dir}/${f}`, type);
644
+ }
645
+ for (const skill of ls(path.join(workspace, "skills"))) add(`skills/${skill}/SKILL.md`, "Skill");
646
+ return out;
647
+ }
648
+
649
+ /** Directory entries, or nothing if the directory isn't there. */
650
+ function ls(dir) {
651
+ return fs.existsSync(dir) ? fs.readdirSync(dir) : [];
652
+ }
653
+
654
+ function bundleDirs(workspace, kind) {
655
+ const out = [];
656
+ const top = path.join(workspace, kind);
657
+ if (fs.existsSync(top)) out.push(top);
658
+ const agentsDir = path.join(workspace, "agents");
659
+ if (fs.existsSync(agentsDir)) {
660
+ for (const a of fs.readdirSync(agentsDir)) {
661
+ const d = path.join(agentsDir, a, kind);
662
+ if (fs.existsSync(d)) out.push(d);
663
+ }
664
+ }
665
+ return out;
666
+ }
667
+
668
+ // ---------------------------------------------------------------- run
669
+
670
+ /**
671
+ * Credentials come from ANTHROPIC_API_KEY or, more often on a laptop, from an
672
+ * existing Claude Code login. Requiring the key outright would lock out anyone
673
+ * already authenticated — a bad first run for the most likely user.
674
+ */
675
+ function assertCredentials() {
676
+ if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return;
677
+ const home = process.env.HOME ?? "";
678
+ if (home && fs.existsSync(path.join(home, ".claude"))) return; // Claude Code login
679
+ throw new Error(
680
+ "no credentials — set ANTHROPIC_API_KEY, or log in with Claude Code.\n" +
681
+ " `foldrun check` works without either.",
682
+ );
683
+ }
684
+
685
+ async function runTarget(target, flags) {
686
+ if (!target) throw new Error("what should I run? try `foldrun run <agent>` or `foldrun run <flow>`");
687
+ assertCredentials();
688
+ const { startFlowRun, loadFlow, listAgents, readRun } = await core();
689
+ const T = "default";
690
+ const P = "workspace";
691
+ const name = target.replace(/^flow:/, "");
692
+ const asFlow = target.startsWith("flow:") || !listAgents(T, P).some((a) => a.name === name);
693
+
694
+ let run;
695
+ if (asFlow) {
696
+ const flow = loadFlow(T, P, name);
697
+ if (!flow) throw new Error(`no agent or flow called "${name}"`);
698
+ const steps = flags.task
699
+ ? flow.steps.map((s, i) =>
700
+ i === 0 ? { ...s, instruction: `${s.instruction}\n\n<run_task>\n${flags.task}\n</run_task>` } : s,
701
+ )
702
+ : flow.steps;
703
+ run = startFlowRun(T, P, steps, flow.name, flow.model);
704
+ } else {
705
+ run = startFlowRun(T, P, [{ agent: name, instruction: flags.task ?? "", group: 1, optional: false }], `cli:${name}`);
706
+ }
707
+
708
+ console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)}\n`);
709
+ const seen = new Map();
710
+ for (;;) {
711
+ const current = readRun(T, P, run.id);
712
+ if (!current) break;
713
+ current.steps.forEach((step, i) => {
714
+ const from = seen.get(i) ?? 0;
715
+ for (const e of step.events.slice(from)) {
716
+ const mark = e.type === "error" ? c.red("✗") : e.type === "tool" ? c.dim("→") : c.dim("·");
717
+ console.log(` ${mark} ${c.dim(step.agent)} ${e.text.split("\n")[0].slice(0, 140)}`);
718
+ }
719
+ seen.set(i, step.events.length);
720
+ });
721
+ if (current.finishedAt) {
722
+ const cost = current.steps.reduce((s, x) => s + (x.costUsd ?? 0), 0);
723
+ const ok = current.status === "completed";
724
+ console.log(
725
+ `\n ${ok ? c.green("✓") : c.red("✗")} ${current.status} · $${cost.toFixed(4)}\n`,
726
+ );
727
+ return ok ? 0 : 1;
728
+ }
729
+ if (current.status === "awaiting-approval") {
730
+ console.log(`\n ${c.amber("paused")} — this flow needs a human. Approve it in the dashboard.\n`);
731
+ return 2;
732
+ }
733
+ await new Promise((r) => setTimeout(r, 700));
734
+ }
735
+ return 1;
736
+ }
737
+
738
+ // ---------------------------------------------------------------- probe
739
+
740
+ /**
741
+ * `foldrun probe <model>` — can this model hold a tool loop, answered by
742
+ * running one. The workspace's provider block is honoured, so the probe
743
+ * exercises the exact path a run takes: same endpoint, same token, same
744
+ * tier remap. The check the run-start gate makes from a catalogue, made
745
+ * from the ground truth instead.
746
+ */
747
+ async function probeCmd(modelArg) {
748
+ if (!modelArg) throw new Error("which model? try `foldrun probe openai/gpt-oss-120b` (or a tier: fast, default, max)");
749
+ assertCredentials();
750
+ const { probeModel, resolveModel, parseProvider, providerEnvFor, resolveEffort, translatorSpecFor, startTranslator, providerPreset, readFrontmatter } = await core();
751
+
752
+ // The workspace's provider block, resolved the way a run resolves it —
753
+ // ${SECRET} values come from the process env here: the CLI's vault is the
754
+ // shell, which is where a laptop keeps its keys anyway. A Chat-Completions
755
+ // provider gets the same translator a run would, on loopback, for the
756
+ // length of the probe — so what passes here passes there.
757
+ let env = { ...process.env };
758
+ let translator = null;
759
+ // No AGENTS.md, or no provider block, means Anthropic direct — like a run.
760
+ // Anything else that goes wrong here is said, not swallowed: a provider
761
+ // block that is silently ignored sends the probe to the wrong endpoint
762
+ // and reports a model that "may not exist".
763
+ const agentsFile = path.join(process.cwd(), "AGENTS.md");
764
+ try {
765
+ const fm = fs.existsSync(agentsFile) ? readFrontmatter(agentsFile) : {};
766
+ const spec = fm.provider ? parseProvider(fm.provider) : null;
767
+ for (const w of spec?.warnings ?? []) console.log(` ${c.yellow("!")} ${w}`);
768
+ if (spec?.baseUrl) {
769
+ const substitute = (t) => t.replace(/\$\{([A-Z][A-Z0-9_]*)\}/g, (whole, name) => process.env[name] ?? whole);
770
+ const token = substitute(spec.token);
771
+ const headers = Object.fromEntries(Object.entries(spec.headers).map(([k, v]) => [k, substitute(v)]));
772
+ env = { ...env, ...providerEnvFor({ baseUrl: spec.baseUrl, token, auth: spec.auth, models: spec.models, headers }) };
773
+ const preset = providerPreset(spec.name);
774
+ const tSpec = translatorSpecFor({
775
+ format: spec.format,
776
+ baseUrl: spec.baseUrl,
777
+ token,
778
+ headers,
779
+ params: spec.params,
780
+ name: spec.name,
781
+ maxTokensParam: preset?.maxTokensParam,
782
+ reasoningEffort: preset?.reasoningEffort,
783
+ });
784
+ if (tSpec) {
785
+ translator = await startTranslator(tSpec);
786
+ env = { ...env, ...translator.env };
787
+ }
788
+ console.log(`
789
+ ${c.dim(`via ${spec.name ? `${spec.name} ` : ""}${spec.baseUrl}${tSpec ? " (through the translator)" : ""}`)}`);
790
+ }
791
+ } catch (err) {
792
+ console.log(` ${c.yellow("!")} provider block not applied: ${err instanceof Error ? err.message : String(err)}`);
793
+ }
794
+
795
+ const model = resolveModel(modelArg);
796
+ process.stdout.write(` probing ${c.bold(model)} ${c.dim("(one tool call, one echo)")} … `);
797
+ let report;
798
+ try {
799
+ report = await probeModel(model, env, resolveEffort(null));
800
+ } finally {
801
+ if (translator) {
802
+ for (const line of translator.drainLog()) console.log(` ${c.dim(line)}`);
803
+ await translator.close();
804
+ }
805
+ }
806
+ console.log(report.ok ? c.green("✓") : c.red("✗"));
807
+ console.log(` tool call made ${report.calledTool ? c.green("yes") : c.red("no")}`);
808
+ console.log(` result read back ${report.echoedNonce ? c.green("yes") : c.red("no")}`);
809
+ console.log(` ${c.dim(`${report.durationMs}ms${report.costUsd != null ? ` · $${report.costUsd.toFixed(4)}` : ""}`)}`);
810
+ if (!report.ok && report.reply) {
811
+ console.log(` ${c.dim("reply:")} ${report.reply.slice(0, 200)}`);
812
+ }
813
+ if (!report.ok) {
814
+ console.log(`
815
+ ${c.amber("this model cannot drive an agent here — pick one that passes, or check the gateway route")}\n`);
816
+ } else {
817
+ console.log(`
818
+ ${c.green("fit to drive an agent")}\n`);
819
+ }
820
+ return report.ok ? 0 : 1;
821
+ }
822
+
823
+ // ---------------------------------------------------------------- eval
824
+
825
+ async function runEvals(name) {
826
+ assertCredentials();
827
+ const { listEvals, runEval } = await core();
828
+ const T = "default";
829
+ const P = "workspace";
830
+ const all = listEvals(T, P).filter((e) => !name || e.name === name);
831
+ if (all.length === 0) throw new Error(name ? `no eval called "${name}"` : "no evals in evals/");
832
+
833
+ let failed = 0;
834
+ for (const info of all) {
835
+ console.log(`\n ${c.bold(info.name)} ${c.dim(`${info.cases.length} cases`)}`);
836
+ const result = await runEval(T, P, info);
837
+ for (const testCase of result.cases) {
838
+ console.log(` ${testCase.passed ? c.green("✓") : c.red("✗")} ${testCase.name}`);
839
+ for (const a of testCase.assertions.filter((x) => !x.passed)) {
840
+ console.log(` ${c.dim(`${a.assertion.type}: ${a.assertion.value}`)} — ${a.detail.split("\n")[0]}`);
841
+ }
842
+ if (testCase.error) console.log(` ${c.red(testCase.error)}`);
843
+ }
844
+ failed += result.failed;
845
+ console.log(` ${result.passed}/${result.passed + result.failed} passing · $${result.costUsd.toFixed(4)}`);
846
+ }
847
+ console.log("");
848
+ return failed ? 1 : 0;
849
+ }
850
+
851
+ // ----------------------------------------------------------------
852
+
853
+
854
+ // ---------------------------------------------------------------- deploy
855
+
856
+ /**
857
+ * Push a directory of markdown into an installation's workspace.
858
+ *
859
+ * The whole point of a markdown platform: there is no build, so deploying is
860
+ * making the files match the source. What earns a command rather than a `cp`
861
+ * is what surrounds the copy — the workspace is checked before any of it is
862
+ * live, and the swap is refused while a run is reading the files.
863
+ */
864
+ /**
865
+ * The same deploy, against a running platform.
866
+ *
867
+ * Returns the same shape the local path does, so the reporting below does not
868
+ * have to know which one it was — a deploy that is refused over HTTP should
869
+ * read exactly like one refused on disk.
870
+ */
871
+ async function deployOverHttp(url, workspace, files, flags) {
872
+ const token = tokenFor(url, flags);
873
+ const endpoint = `${url.replace(/\/+$/, "")}/api/workspaces/${encodeURIComponent(workspace)}/deploy`;
874
+
875
+ let res;
876
+ try {
877
+ res = await fetch(endpoint, {
878
+ method: "POST",
879
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
880
+ body: JSON.stringify({
881
+ files,
882
+ commit: flags.commit ?? null,
883
+ force: flags.force === true,
884
+ dryRun: flags["dry-run"] === true,
885
+ }),
886
+ });
887
+ } catch (err) {
888
+ throw new Error(`could not reach ${url} — ${err instanceof Error ? err.message : String(err)}`);
889
+ }
890
+
891
+ const body = await res.json().catch(() => ({}));
892
+ if (res.status === 401) throw new Error(`${body.error ?? "unauthorized"} — run \`foldrun login\` again, or check FOLDRUN_TOKEN`);
893
+ // 422 is a refusal the caller has to read, not a transport failure: the
894
+ // issues are in the body and reported like any other refused deploy.
895
+ if (!res.ok && res.status !== 422) {
896
+ throw new Error(body.error ?? `${url} returned ${res.status}`);
897
+ }
898
+
899
+ return {
900
+ added: body.added ?? [],
901
+ updated: body.updated ?? [],
902
+ removed: body.removed ?? [],
903
+ issues: body.issues ?? [],
904
+ blockedBy: body.blockedBy ?? [],
905
+ preserved: body.preserved ?? 0,
906
+ commit: body.commit ?? null,
907
+ };
908
+ }
909
+
910
+ async function deploy(source, flags) {
911
+ const { readTree, planDeploy, deployWorkspace, deployedCommit } = await core();
912
+
913
+ if (!fs.existsSync(source)) throw new Error(`no such directory: ${source}`);
914
+ const workspace = flags.to ?? path.basename(path.resolve(source));
915
+ const tenant = flags.tenant ?? "default";
916
+
917
+ const files = readTree(source);
918
+
919
+ // Two destinations, one command. With a platform named — by --url, by
920
+ // FOLDRUN_URL, or by having signed in — the workspace is POSTed to it,
921
+ // which is what a laptop or a CI job does. Without one, or with --local,
922
+ // it is written straight into the installation on this machine.
923
+ const url = flags.local === true ? undefined : remoteUrl(flags);
924
+ const plan = url
925
+ ? await deployOverHttp(url, workspace, files, flags)
926
+ : flags["dry-run"]
927
+ ? planDeploy(tenant, workspace, files)
928
+ : deployWorkspace(tenant, workspace, files, {
929
+ commit: flags.commit ?? null,
930
+ force: flags.force === true,
931
+ });
932
+
933
+ console.log(
934
+ `\n ${c.bold(url ? `${url} ${workspace}` : `${tenant}/${workspace}`)} ` +
935
+ `${c.dim(`← ${path.resolve(source)}`)}`,
936
+ );
937
+ console.log(
938
+ ` ${c.dim(`${files.length} files · +${plan.added.length} ~${plan.updated.length} -${plan.removed.length}`)}\n`,
939
+ );
940
+
941
+ const show = (label, list, colour) => {
942
+ for (const f of list.slice(0, 20)) console.log(` ${colour(label)} ${f}`);
943
+ if (list.length > 20) console.log(` ${c.dim(`… and ${list.length - 20} more`)}`);
944
+ };
945
+ show("+", plan.added, c.green);
946
+ show("~", plan.updated, c.dim);
947
+ show("-", plan.removed, c.red);
948
+
949
+ if (plan.issues.length) {
950
+ console.log(`\n ${c.red(`${plan.issues.length} problem${plan.issues.length === 1 ? "" : "s"}`)} — nothing was deployed\n`);
951
+ for (const i of plan.issues) console.log(` ${c.red("✗")} ${c.bold(i.where)} ${i.message}`);
952
+ console.log();
953
+ return 1;
954
+ }
955
+
956
+ if (plan.blockedBy.length && !flags.force) {
957
+ console.log(
958
+ `\n ${c.amber("⏸")} ${plan.blockedBy.length} run${plan.blockedBy.length === 1 ? " is" : "s are"} still using these files: ` +
959
+ `${plan.blockedBy.join(", ")}\n ${c.dim("wait for them to finish, or --force to deploy anyway")}\n`,
960
+ );
961
+ return 1;
962
+ }
963
+
964
+ if (flags["dry-run"]) {
965
+ console.log(`\n ${c.dim("checks out — run without --dry-run to deploy")}\n`);
966
+ return 0;
967
+ }
968
+
969
+ const at = url ? { commit: plan.commit } : deployedCommit(tenant, workspace);
970
+ console.log(
971
+ `\n ${c.green("✓")} deployed${at?.commit ? ` ${c.dim(at.commit.slice(0, 8))}` : ""}` +
972
+ `${plan.preserved ? c.dim(` · kept ${plan.preserved} file${plan.preserved === 1 ? "" : "s"} the agents own`) : ""}\n`,
973
+ );
974
+ return 0;
975
+ }
976
+
977
+ // ---------------------------------------------------------------- secrets
978
+
979
+ /** Read a value without echoing it. A secret typed into a terminal should
980
+ * not sit in the scrollback afterwards. */
981
+ function promptHidden(question) {
982
+ return new Promise((resolve, reject) => {
983
+ process.stdout.write(question);
984
+ const { stdin } = process;
985
+ if (!stdin.isTTY) {
986
+ // Piped input (echo "$VALUE" | foldrun secrets set NAME) — read a line.
987
+ let buf = "";
988
+ stdin.setEncoding("utf8");
989
+ stdin.on("data", (d) => (buf += d));
990
+ stdin.on("end", () => resolve(buf.replace(/\n$/, "")));
991
+ return;
992
+ }
993
+ stdin.setRawMode(true);
994
+ stdin.resume();
995
+ stdin.setEncoding("utf8");
996
+ let value = "";
997
+ const onData = (ch) => {
998
+ if (ch === "\u0003") {
999
+ cleanup();
1000
+ reject(new Error("cancelled"));
1001
+ } else if (ch === "\r" || ch === "\n") {
1002
+ cleanup();
1003
+ process.stdout.write("\n");
1004
+ resolve(value);
1005
+ } else if (ch === "\u007f" || ch === "\b") {
1006
+ value = value.slice(0, -1);
1007
+ } else {
1008
+ value += ch;
1009
+ }
1010
+ };
1011
+ const cleanup = () => {
1012
+ stdin.setRawMode(false);
1013
+ stdin.pause();
1014
+ stdin.off("data", onData);
1015
+ };
1016
+ stdin.on("data", onData);
1017
+ });
1018
+ }
1019
+
1020
+ /** Read a line with normal echo — for the non-secret halves of a config. */
1021
+ function promptVisible(question) {
1022
+ return new Promise((resolve) => {
1023
+ process.stdout.write(question);
1024
+ const { stdin } = process;
1025
+ stdin.resume();
1026
+ stdin.setEncoding("utf8");
1027
+ stdin.once("data", (line) => {
1028
+ stdin.pause();
1029
+ resolve(String(line).replace(/\r?\n$/, ""));
1030
+ });
1031
+ });
1032
+ }
1033
+
1034
+ /**
1035
+ * Which platform, and with what.
1036
+ *
1037
+ * URL: --url, then FOLDRUN_URL, then whatever `foldrun login` last signed in
1038
+ * to. Token: --token, then FOLDRUN_TOKEN, then the key stored for that URL.
1039
+ * The environment beats the file on purpose — a CI job with FOLDRUN_TOKEN
1040
+ * set never reads a laptop's credentials, and a person who exports a token
1041
+ * to test as someone else gets exactly that.
1042
+ */
1043
+ // An empty variable is an unset one: `export FOLDRUN_TOKEN=` in a profile
1044
+ // should not shadow the credentials file with nothing.
1045
+ const env = (name) => process.env[name] || undefined;
1046
+
1047
+ const remoteUrl = (flags) => flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? undefined;
1048
+
1049
+ const NOT_SIGNED_IN = "not signed in — run `foldrun login`, or set FOLDRUN_TOKEN / pass --token";
1050
+
1051
+ function tokenFor(url, flags) {
1052
+ const token = flags.token ?? env("FOLDRUN_TOKEN") ?? credentialFor(url)?.token;
1053
+ if (!token) throw new Error(NOT_SIGNED_IN);
1054
+ return token;
1055
+ }
1056
+
1057
+ async function remoteCall(url, flags, apiPath, init = {}) {
1058
+ const token = tokenFor(url, flags);
1059
+ const res = await fetch(new URL(apiPath, url), {
1060
+ ...init,
1061
+ headers: {
1062
+ authorization: `Bearer ${token}`,
1063
+ "content-type": "application/json",
1064
+ ...(init.headers ?? {}),
1065
+ },
1066
+ });
1067
+ const body = await res.json().catch(() => ({}));
1068
+ if (!res.ok) throw new Error(body.error ?? `${apiPath} → HTTP ${res.status}`);
1069
+ return body;
1070
+ }
1071
+
1072
+ /** Print a run's events as they arrive: one line per event, once each. */
1073
+ function printNew(run, seen) {
1074
+ run.steps.forEach((step, i) => {
1075
+ const from = seen.get(i) ?? 0;
1076
+ for (const e of step.events.slice(from)) {
1077
+ const mark = e.type === "error" ? c.red("✗") : e.type === "tool" ? c.dim("→") : c.dim("·");
1078
+ console.log(` ${mark} ${c.dim(step.agent)} ${e.text.split("\n")[0].slice(0, 140)}`);
1079
+ }
1080
+ seen.set(i, step.events.length);
1081
+ });
1082
+ }
1083
+
1084
+ function finishLine(run) {
1085
+ const cost = run.steps.reduce((s, x) => s + (x.costUsd ?? 0), 0);
1086
+ const ok = run.status === "completed";
1087
+ console.log(`\n ${ok ? c.green("✓") : run.status === "awaiting-approval" ? c.amber("⏸") : c.red("✗")} ${run.status} · $${cost.toFixed(4)}\n`);
1088
+ return ok ? 0 : run.status === "awaiting-approval" ? 2 : 1;
1089
+ }
1090
+
1091
+ /**
1092
+ * Follow a run on a platform: the same server-sent events the dashboard's
1093
+ * run page reads, printed as they land. Resolves with the finished run.
1094
+ */
1095
+ async function followRemote(url, flags, ws, runId, seen = new Map()) {
1096
+ const token = tokenFor(url, flags);
1097
+ const res = await fetch(new URL(`/api/workspaces/${ws}/runs/${runId}/stream`, url), {
1098
+ headers: { authorization: `Bearer ${token}`, accept: "text/event-stream" },
1099
+ });
1100
+ if (!res.ok || !res.body) throw new Error(`stream → HTTP ${res.status}`);
1101
+ const reader = res.body.getReader();
1102
+ const decoder = new TextDecoder();
1103
+ let buffer = "";
1104
+ let last = null;
1105
+ for (;;) {
1106
+ const { value, done } = await reader.read();
1107
+ if (done) break;
1108
+ buffer += decoder.decode(value, { stream: true });
1109
+ let cut;
1110
+ while ((cut = buffer.indexOf("\n\n")) !== -1) {
1111
+ const frame = buffer.slice(0, cut);
1112
+ buffer = buffer.slice(cut + 2);
1113
+ const event = frame.match(/^event: (.*)$/m)?.[1];
1114
+ const data = frame.match(/^data: (.*)$/m)?.[1];
1115
+ if (!event || !data) continue;
1116
+ if (event === "run") {
1117
+ try {
1118
+ last = JSON.parse(data);
1119
+ printNew(last, seen);
1120
+ } catch {
1121
+ // a partial frame; the next one supersedes it
1122
+ }
1123
+ } else if (event === "done") {
1124
+ try {
1125
+ await reader.cancel();
1126
+ } catch {
1127
+ // the server ended it first
1128
+ }
1129
+ return last;
1130
+ }
1131
+ }
1132
+ }
1133
+ return last;
1134
+ }
1135
+
1136
+ /**
1137
+ * `foldrun open [page]` — the dashboard, from the terminal: this
1138
+ * workspace's overview, or one of its pages (runs, agents, flows, graph,
1139
+ * repo…). Prints the URL and opens it, or only prints with --print.
1140
+ */
1141
+ async function openCmd(positional, flags) {
1142
+ const url = remoteUrl(flags);
1143
+ if (!url) throw new Error("open needs a platform — pass --url or set FOLDRUN_URL");
1144
+ const ws = flags.to ?? path.basename(process.env.FOLDRUN_WORKSPACE ?? process.cwd());
1145
+ const page = positional[0] ? `/${positional[0].replace(/^\/+/, "")}` : "";
1146
+ const target = new URL(`/dashboard/${ws}${page}`, url).toString();
1147
+ console.log(target);
1148
+ if (flags.print === true) return 0;
1149
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1150
+ try {
1151
+ const { spawn } = await import("node:child_process");
1152
+ spawn(opener, [target], { detached: true, stdio: "ignore" }).unref();
1153
+ } catch {
1154
+ // printed above; that is enough
1155
+ }
1156
+ return 0;
1157
+ }
1158
+
1159
+ /**
1160
+ * `foldrun secrets set|ls|rm` — the vault, from the terminal.
1161
+ *
1162
+ * Local by default (the workspace's own secrets.json, encrypted under the
1163
+ * install key); with --url/FOLDRUN_URL the same three verbs go to a running
1164
+ * platform. Values are prompted without echo unless piped or passed with
1165
+ * --value, and are never printed back by any verb.
1166
+ */
1167
+ async function secretsCmd(positional, flags) {
1168
+ const [verb, name] = positional;
1169
+ // --local: this machine's store even when signed in, the same escape
1170
+ // hatch deploy and keys have. Without it, a signed-in laptop sent every
1171
+ // secret to the platform and the local store could not be reached at all.
1172
+ const url = flags.local === true ? undefined : remoteUrl(flags);
1173
+ // The scope is the workspace's own store, named the way the runner names
1174
+ // it — the folder's basename. It was the literal string "workspace", so a
1175
+ // secret set from the terminal landed under workspaces/workspace/ and every
1176
+ // run, reading under workspaces/<name>/, reported it missing. The same
1177
+ // scope goes to the platform: it used to take only --to there, so
1178
+ // `--workspace .` was silently an account-wide secret.
1179
+ const localWorkspace = path.basename(process.env.FOLDRUN_WORKSPACE ?? process.cwd());
1180
+ const scope = flags.account === true ? undefined : flags.to ?? localWorkspace;
1181
+
1182
+ if (verb === "ls" || verb === undefined) {
1183
+ const entries = url
1184
+ ? (await remoteCall(url, flags, `/api/secrets${scope ? `?workspace=${encodeURIComponent(scope)}` : ""}`)).secrets
1185
+ : (await core()).listSecrets("default", scope);
1186
+ if (!entries.length) {
1187
+ console.log(`\n ${c.dim("no secrets yet — foldrun secrets set NAME")}\n`);
1188
+ return 0;
1189
+ }
1190
+ console.log();
1191
+ for (const s of entries) {
1192
+ console.log(
1193
+ ` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}`)}`,
1194
+ );
1195
+ }
1196
+ console.log();
1197
+ return 0;
1198
+ }
1199
+
1200
+ if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
1201
+
1202
+ if (verb === "set") {
1203
+ // --oauth2: store a refresh recipe instead of a static value. The
1204
+ // platform exchanges it for a live access token before every use.
1205
+ if (flags.oauth2 === true) {
1206
+ const token_url =
1207
+ (await promptVisible(" token URL [https://oauth2.googleapis.com/token]: ")) ||
1208
+ "https://oauth2.googleapis.com/token";
1209
+ const client_id = await promptVisible(" client_id: ");
1210
+ const client_secret = await promptHidden(" client_secret: ");
1211
+ const refresh_token = await promptHidden(" refresh_token: ");
1212
+ const config = { token_url, client_id, client_secret, refresh_token };
1213
+ if (url) {
1214
+ await remoteCall(url, flags, "/api/secrets", {
1215
+ method: "PUT",
1216
+ body: JSON.stringify({ name, oauth2: config, workspace: scope }),
1217
+ });
1218
+ } else {
1219
+ (await core()).setOAuth2Secret("default", name, config, scope);
1220
+ }
1221
+ console.log(`\n ${c.green("✓")} ${name} stored as an auto-refreshing oauth2 credential\n`);
1222
+ return 0;
1223
+ }
1224
+
1225
+ const value =
1226
+ typeof flags.value === "string" ? flags.value : await promptHidden(` value for ${name}: `);
1227
+ if (!value) throw new Error("empty value — nothing stored");
1228
+ if (url) {
1229
+ await remoteCall(url, flags, "/api/secrets", {
1230
+ method: "PUT",
1231
+ body: JSON.stringify({ name, value, workspace: scope }),
1232
+ });
1233
+ } else {
1234
+ (await core()).setSecret("default", name, value, scope);
1235
+ }
1236
+ console.log(`\n ${c.green("✓")} ${name} stored — declare it in agent.md under \`secrets:\` to use it\n`);
1237
+ return 0;
1238
+ }
1239
+
1240
+ if (verb === "rm") {
1241
+ if (url) {
1242
+ await remoteCall(url, flags, "/api/secrets", {
1243
+ method: "DELETE",
1244
+ body: JSON.stringify({ name, workspace: scope }),
1245
+ });
1246
+ } else {
1247
+ (await core()).deleteSecret("default", name, scope);
1248
+ }
1249
+ console.log(`\n ${c.green("✓")} ${name} removed\n`);
1250
+ return 0;
1251
+ }
1252
+
1253
+ throw new Error(`unknown secrets verb "${verb}" — set, ls or rm`);
1254
+ }
1255
+
1256
+ // ---------------------------------------------------------------- logs
1257
+
1258
+ const EVENT_MARK = (e) =>
1259
+ e.type === "error" ? c.red("✗") : e.type === "tool" ? c.dim("→") : c.dim("·");
1260
+
1261
+ /**
1262
+ * `foldrun logs [run-id]` — without an id, the recent runs; with one, that
1263
+ * run's whole event log. `--follow` keeps tailing a live run.
1264
+ */
1265
+ async function logsCmd(positional, flags) {
1266
+ // With a platform named, the same verbs read the server's runs: the list,
1267
+ // one run's trail, or a live one followed to the end.
1268
+ const url = remoteUrl(flags);
1269
+ if (url) return remoteLogs(url, positional, flags);
1270
+
1271
+ const { listRuns, readRun } = await core();
1272
+ const T = "default";
1273
+ const P = "workspace";
1274
+ const runId = positional[0];
1275
+
1276
+ if (!runId) {
1277
+ const runs = listRuns(T, P).slice(0, 20);
1278
+ if (!runs.length) {
1279
+ console.log(`\n ${c.dim("no runs yet — foldrun run <agent or flow>")}\n`);
1280
+ return 0;
1281
+ }
1282
+ console.log();
1283
+ for (const r of runs) {
1284
+ const cost = r.steps.reduce((s, x) => s + (x.costUsd ?? 0), 0);
1285
+ const mark =
1286
+ r.status === "completed" ? c.green("✓") : r.status === "failed" ? c.red("✗") : c.amber("…");
1287
+ console.log(
1288
+ ` ${mark} ${c.bold(r.id)} ${r.flow} ${c.dim(`${r.status} · $${cost.toFixed(4)} · ${r.startedAt}`)}`,
1289
+ );
1290
+ }
1291
+ console.log(`\n ${c.dim("foldrun logs <run-id> for the full trail")}\n`);
1292
+ return 0;
1293
+ }
1294
+
1295
+ const print = (run, seen) => {
1296
+ run.steps.forEach((step, i) => {
1297
+ const from = seen.get(i) ?? 0;
1298
+ for (const e of step.events.slice(from)) {
1299
+ console.log(` ${EVENT_MARK(e)} ${c.dim(e.t)} ${c.bold(step.agent)} ${e.text}`);
1300
+ }
1301
+ seen.set(i, step.events.length);
1302
+ });
1303
+ };
1304
+
1305
+ const seen = new Map();
1306
+ let run = readRun(T, P, runId);
1307
+ if (!run) throw new Error(`no run called "${runId}" here — \`foldrun logs\` lists them`);
1308
+ console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)} ${c.dim(run.status)}\n`);
1309
+ print(run, seen);
1310
+
1311
+ while (flags.follow === true && !run.finishedAt) {
1312
+ await new Promise((r) => setTimeout(r, 700));
1313
+ run = readRun(T, P, runId);
1314
+ if (!run) break;
1315
+ print(run, seen);
1316
+ }
1317
+ if (run?.finishedAt) {
1318
+ const cost = run.steps.reduce((s, x) => s + (x.costUsd ?? 0), 0);
1319
+ console.log(`\n ${run.status === "completed" ? c.green("✓") : c.red("✗")} ${run.status} · $${cost.toFixed(4)}\n`);
1320
+ } else {
1321
+ console.log();
1322
+ }
1323
+ return run?.status === "failed" ? 1 : 0;
1324
+ }
1325
+
1326
+ // ---------------------------------------------------------------- invoke
1327
+
1328
+ async function remoteLogs(url, positional, flags) {
1329
+ const ws = flags.to ?? path.basename(process.env.FOLDRUN_WORKSPACE ?? process.cwd());
1330
+ const runId = positional[0];
1331
+ if (!runId) {
1332
+ const { runs } = await remoteCall(url, flags, `/api/workspaces/${ws}/runs?limit=20`);
1333
+ if (!runs?.length) {
1334
+ console.log(`\n ${c.dim(`no runs in ${ws} on ${url}`)}\n`);
1335
+ return 0;
1336
+ }
1337
+ console.log();
1338
+ for (const r of runs) {
1339
+ const cost = r.steps.reduce((s, x) => s + (x.costUsd ?? 0), 0);
1340
+ const mark = r.status === "completed" ? c.green("✓") : r.status === "failed" ? c.red("✗") : c.amber("…");
1341
+ console.log(` ${mark} ${c.bold(r.id)} ${r.flow} ${c.dim(`${r.status} · $${cost.toFixed(4)} · ${r.startedAt}`)}${r.summary ? `\n ${c.dim(r.summary)}` : ""}`);
1342
+ }
1343
+ console.log(`\n ${c.dim("foldrun logs <run-id> --to " + ws + " for the full trail; --follow tails a live one")}\n`);
1344
+ return 0;
1345
+ }
1346
+ let run = await remoteCall(url, flags, `/api/workspaces/${ws}/runs/${runId}`);
1347
+ console.log(`\n ${c.bold(run.flow)} ${c.dim(run.id)} ${c.dim(run.status)}\n`);
1348
+ const seen = new Map();
1349
+ printNew(run, seen);
1350
+ const live = run.status === "queued" || run.status === "running" || run.status === "awaiting-approval";
1351
+ if (flags.follow === true && live) run = (await followRemote(url, flags, ws, runId, seen)) ?? run;
1352
+ return finishLine(run);
1353
+ }
1354
+
1355
+ /**
1356
+ * `foldrun invoke <flow>` — start a flow on a running platform. The remote
1357
+ * sibling of `foldrun run`: same task flag, but the run continues on the
1358
+ * server whether or not this terminal sticks around. `--wait` holds on for
1359
+ * the result like an RPC.
1360
+ */
1361
+ async function invoke(target, flags) {
1362
+ const url = remoteUrl(flags);
1363
+ if (!url) {
1364
+ throw new Error(
1365
+ "invoke starts a flow on a platform — pass --url or set FOLDRUN_URL. (Running locally? That's `foldrun run`.)",
1366
+ );
1367
+ }
1368
+ if (!target) throw new Error("which flow? try `foldrun invoke <flow> --to <workspace>`");
1369
+ const ws = flags.to;
1370
+ if (!ws) throw new Error("which workspace is it in? pass --to <workspace>");
1371
+
1372
+ const wait = flags.wait === true ? "?wait=true" : "";
1373
+ // --from N starts at step N of the flow as its file numbers them; the
1374
+ // earlier steps are recorded as skipped. Mutually exclusive with --task
1375
+ // server-side (the task goes to step 1, which --from skips).
1376
+ const from = flags.from !== undefined ? Number(flags.from) : undefined;
1377
+ const body = await remoteCall(url, flags, `/api/workspaces/${ws}/flows/${target}/run${wait}`, {
1378
+ method: "POST",
1379
+ body: JSON.stringify({
1380
+ task: typeof flags.task === "string" ? flags.task : "",
1381
+ ...(from !== undefined ? { from } : {}),
1382
+ }),
1383
+ });
1384
+
1385
+ if (flags.watch === true && body.runId) {
1386
+ // Queued, then followed: the trace lands here line by line, the way the
1387
+ // run page draws it, and the exit code is the run's.
1388
+ console.log(`\n ${c.bold(target)} ${c.dim(body.runId)}\n`);
1389
+ const run = await followRemote(url, flags, ws, body.runId);
1390
+ if (!run) {
1391
+ console.log(` ${c.dim("the stream ended before the run did — foldrun logs " + body.runId + " --to " + ws)}\n`);
1392
+ return 0;
1393
+ }
1394
+ return finishLine(run);
1395
+ }
1396
+ if (!flags.wait) {
1397
+ console.log(`\n ${c.green("✓")} queued ${c.bold(body.runId)} — ${c.dim(`foldrun logs ${body.runId} --to ${ws} --follow, or ${url}/dashboard/${ws}/runs?run=${body.runId}`)}\n`);
1398
+ return 0;
1399
+ }
1400
+ const run = body.run ?? body;
1401
+ const ok = (run.status ?? body.status) === "completed";
1402
+ if (body.result) console.log(`\n${body.result}\n`);
1403
+ console.log(` ${ok ? c.green("✓") : c.red("✗")} ${run.status ?? body.status ?? "finished"}${body.costUsd != null ? ` · $${Number(body.costUsd).toFixed(4)}` : ""}\n`);
1404
+ return ok ? 0 : 1;
1405
+ }
1406
+
1407
+ // ---------------------------------------------------------------- connect
1408
+
1409
+ /** The loopback port `foldrun connect` listens on. Documented; do not change casually. */
1410
+ const CONNECT_PORT = 8642;
1411
+
1412
+ /**
1413
+ * `foldrun connect NAME --provider linkedin` — the OAuth consent from the
1414
+ * terminal, the way `gh auth login` and `gcloud auth login` do it: open the
1415
+ * provider's screen, catch the redirect on a loopback port on this machine,
1416
+ * trade the code for tokens, and store the result on the platform (or in the
1417
+ * local vault) as the auto-refreshing secret an agent's `secrets:` names.
1418
+ *
1419
+ * Why loopback and not the dashboard's callback: every provider requires the
1420
+ * redirect to be registered in advance and most refuse plain http anywhere
1421
+ * but localhost. A developer's laptop always has localhost; a box behind a
1422
+ * tunnel or a LAN address does not have a name a provider will accept. So the
1423
+ * callback is `http://localhost:<port>/callback`, printed before the browser
1424
+ * opens so it can be registered first, and the port is fixed (--port) because
1425
+ * providers match it exactly.
1426
+ *
1427
+ * Nothing secret is printed. The refresh token goes straight into the vault;
1428
+ * a provider that issues none (GitHub, most LinkedIn apps) gets its access
1429
+ * token stored as a static value and the expiry is said out loud.
1430
+ */
1431
+ async function connect(positional, flags) {
1432
+ const name = positional[0];
1433
+ if (!name) throw new Error("usage: foldrun connect NAME --provider <google|github|microsoft|linkedin> [--workspace <name>]");
1434
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name)) throw new Error(`secret name "${name}" must be UPPER_SNAKE_CASE`);
1435
+ const { OAUTH_PRESETS } = await core();
1436
+
1437
+ const providerName = flags.provider;
1438
+ const preset = providerName ? OAUTH_PRESETS[providerName] : undefined;
1439
+ if (providerName && !preset) {
1440
+ throw new Error(`unknown provider "${providerName}" — one of ${Object.keys(OAUTH_PRESETS).join(", ")}, or pass --authorize-url and --token-url`);
1441
+ }
1442
+ const authorizeUrl = flags["authorize-url"] ?? preset?.authorize_url;
1443
+ const tokenUrl = flags["token-url"] ?? preset?.token_url;
1444
+ if (!authorizeUrl || !tokenUrl) throw new Error("--provider, or both --authorize-url and --token-url");
1445
+ if (!/^https:\/\//.test(tokenUrl) && !/^http:\/\/(127\.0\.0\.1|localhost)[:/]/.test(tokenUrl)) {
1446
+ throw new Error("token URL must be https — a refresh token over http is a leaked one");
1447
+ }
1448
+
1449
+ // Where the result goes: the platform when one is named or signed in,
1450
+ // this machine's vault otherwise. Decided before the browser opens, so a
1451
+ // consent is never spent on a store that then refuses it.
1452
+ const url = flags.local === true ? undefined : remoteUrl(flags);
1453
+ const token = url ? tokenFor(url, flags) : null;
1454
+ const workspaceName = flags.to ?? (flags.workspace ? path.basename(path.resolve(flags.workspace)) : undefined);
1455
+
1456
+ const clientId = flags["client-id"] ?? env("OAUTH_CLIENT_ID") ?? (await promptVisible(" client_id: "));
1457
+ const clientSecret = flags["client-secret"] ?? env("OAUTH_CLIENT_SECRET") ?? (await promptHidden(" client_secret: "));
1458
+ if (!clientId || !clientSecret) throw new Error("client_id and client_secret are required");
1459
+ const scopes = flags.scopes ?? preset?.scopes_example ?? "";
1460
+
1461
+ // One fixed loopback address, the same on every developer's machine, so an
1462
+ // OAuth app is set up once — `http://localhost:8642/callback` — and never
1463
+ // per person. Fixed because most providers match the port exactly; 8642
1464
+ // because 3000 is every dev server. --port / FOLDRUN_CONNECT_PORT override
1465
+ // it, and then the app needs that value registered too.
1466
+ const port = Number(flags.port ?? env("FOLDRUN_CONNECT_PORT") ?? CONNECT_PORT);
1467
+ const redirectUri = `http://localhost:${port}/callback`;
1468
+ if (preset?.hint) console.log(`\n ${c.dim(preset.hint)}`);
1469
+ console.log(`\n Register this redirect URL on the ${providerName ?? "provider"} app once — it is the same for every developer:\n ${c.bold(redirectUri)}\n`);
1470
+
1471
+ const crypto = await import("node:crypto");
1472
+ const http = await import("node:http");
1473
+ const state = crypto.randomBytes(24).toString("hex");
1474
+ const authorize = new URL(authorizeUrl);
1475
+ authorize.searchParams.set("response_type", "code");
1476
+ authorize.searchParams.set("client_id", clientId);
1477
+ authorize.searchParams.set("redirect_uri", redirectUri);
1478
+ authorize.searchParams.set("state", state);
1479
+ if (scopes) authorize.searchParams.set("scope", scopes);
1480
+ for (const [k, v] of Object.entries(preset?.authorize_extra ?? {})) authorize.searchParams.set(k, v);
1481
+
1482
+ // One request, then the listener closes. The state is the whole authority:
1483
+ // a callback with any other value is answered and ignored.
1484
+ const code = await new Promise((resolve, reject) => {
1485
+ const server = http.createServer((req, res) => {
1486
+ const u = new URL(req.url ?? "/", redirectUri);
1487
+ if (u.pathname !== "/callback") { res.statusCode = 404; res.end(); return; }
1488
+ if (u.searchParams.get("state") !== state) { res.statusCode = 400; res.end("state mismatch — start again"); return; }
1489
+ const err = u.searchParams.get("error");
1490
+ if (err) {
1491
+ res.end(`${err}: ${u.searchParams.get("error_description") ?? ""}. You can close this tab.`);
1492
+ server.close();
1493
+ reject(new Error(`${providerName ?? "provider"} refused: ${err} ${u.searchParams.get("error_description") ?? ""}`.trim()));
1494
+ return;
1495
+ }
1496
+ res.end("Connected. You can close this tab and return to the terminal.");
1497
+ server.close();
1498
+ resolve(u.searchParams.get("code"));
1499
+ });
1500
+ server.on("error", (e) => reject(e.code === "EADDRINUSE"
1501
+ ? new Error(`port ${port} is in use — pass --port <n> and register http://localhost:<n>/callback on the app`)
1502
+ : e));
1503
+ server.listen(port, "127.0.0.1", async () => {
1504
+ const opened = flags["no-browser"] === true ? false : await openInBrowser(authorize.toString());
1505
+ console.log(opened
1506
+ ? ` Opened your browser. Approve as the account that owns the ${providerName ?? "provider"} resource.\n`
1507
+ : ` Open this address in your browser:\n\n ${authorize.toString()}\n`);
1508
+ console.log(` ${c.dim("Waiting for the redirect…")}`);
1509
+ });
1510
+ });
1511
+ if (!code) throw new Error("the redirect carried no code");
1512
+
1513
+ const exchange = await fetch(tokenUrl, {
1514
+ method: "POST",
1515
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1516
+ body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret }).toString(),
1517
+ });
1518
+ const payload = await exchange.json().catch(() => ({}));
1519
+ if (!exchange.ok || (!payload.refresh_token && !payload.access_token)) {
1520
+ throw new Error(`token exchange failed (${exchange.status}): ${payload.error_description ?? payload.error ?? "no token in the reply"}`);
1521
+ }
1522
+
1523
+ // Store: the refresh recipe when there is one, the bare token otherwise.
1524
+ const body = payload.refresh_token
1525
+ ? { name, oauth2: { token_url: tokenUrl, client_id: clientId, client_secret: clientSecret, refresh_token: payload.refresh_token }, workspace: workspaceName }
1526
+ : { name, value: payload.access_token, workspace: workspaceName };
1527
+ if (url) {
1528
+ // The same PUT `secrets set` makes. A store that fails here has spent the
1529
+ // consent — the code is single-use and nothing secret is kept locally —
1530
+ // so say that rather than leave "HTTP 405" to explain itself.
1531
+ try {
1532
+ await remoteCall(url, flags, "/api/secrets", { method: "PUT", body: JSON.stringify(body) });
1533
+ } catch (err) {
1534
+ throw new Error(`${providerName ?? "the provider"} approved, but storing ${name} on ${url} failed: ${err instanceof Error ? err.message : err}. The consent is spent; fix the platform side and run this again.`);
1535
+ }
1536
+ } else {
1537
+ const { setSecret, setOAuth2Secret } = await core();
1538
+ if (body.oauth2) setOAuth2Secret("default", name, body.oauth2, workspaceName);
1539
+ else setSecret("default", name, body.value, workspaceName);
1540
+ }
1541
+
1542
+ const where = `${url ?? "this machine"}${workspaceName ? ` · ${workspaceName}` : " · account"}`;
1543
+ if (payload.refresh_token) {
1544
+ console.log(`\n ${c.green("✓")} ${name} stored as an auto-refreshing oauth2 credential on ${where}\n`);
1545
+ } else {
1546
+ const days = payload.expires_in ? Math.round(payload.expires_in / 86400) : null;
1547
+ console.log(`\n ${c.green("✓")} ${name} stored on ${where}`);
1548
+ console.log(` ${c.amber("!")} ${providerName ?? "the provider"} issued no refresh token — this access token expires${days ? ` in about ${days} days` : ""}; run this command again then.\n`);
1549
+ }
1550
+ if (payload.scope) console.log(` ${c.dim(`granted scopes: ${payload.scope}`)}\n`);
1551
+ return 0;
1552
+ }
1553
+
1554
+ // ---------------------------------------------------------------- login
1555
+
1556
+ const DEFAULT_PLATFORM = "https://app.foldrun.io";
1557
+
1558
+ function openInBrowser(target) {
1559
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
1560
+ return import("node:child_process")
1561
+ .then(({ spawn }) => {
1562
+ const child = spawn(opener, [target], { detached: true, stdio: "ignore" });
1563
+ child.on("error", () => {});
1564
+ child.unref();
1565
+ return true;
1566
+ })
1567
+ .catch(() => false);
1568
+ }
1569
+
1570
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
1571
+
1572
+ /**
1573
+ * `foldrun login [--url]` — sign this machine in from the browser.
1574
+ *
1575
+ * The platform hands back a short code; the browser opens on the page that
1576
+ * asks "is this your terminal?"; the person says yes; the key that makes
1577
+ * lands here and is kept in ~/.foldrun/credentials.json. No key to copy,
1578
+ * nothing pasted into a shell history. `--token` skips the browser and
1579
+ * stores a key made in the dashboard — for a machine with no browser, or a
1580
+ * deploy key for one workspace.
1581
+ */
1582
+ async function login(flags) {
1583
+ const url = normaliseUrl(flags.url ?? env("FOLDRUN_URL") ?? defaultPlatform() ?? DEFAULT_PLATFORM);
1584
+
1585
+ if (typeof flags.token === "string") {
1586
+ // Verify before storing: a wrong key stored is a wrong key on every
1587
+ // later command, each failing one step further from the cause.
1588
+ const me = await remoteCall(url, { token: flags.token }, "/api/me");
1589
+ saveCredential(url, { token: flags.token, email: me.actor.email ?? null, account: me.account, role: me.role });
1590
+ console.log(`\n ${c.green("✓")} signed in to ${c.bold(url)} as ${me.actor.email ?? me.actor.label ?? "an API key"} ${c.dim(`(${me.account}, ${me.role})`)}\n`);
1591
+ return 0;
1592
+ }
1593
+
1594
+ let start;
1595
+ try {
1596
+ const res = await fetch(new URL("/api/cli/login", url), {
1597
+ method: "POST",
1598
+ headers: { "content-type": "application/json" },
1599
+ body: JSON.stringify({ hostname: os.hostname() }),
1600
+ });
1601
+ start = await res.json().catch(() => ({}));
1602
+ if (!res.ok) throw new Error(start.error ?? `HTTP ${res.status}`);
1603
+ } catch (err) {
1604
+ throw new Error(`could not start a sign-in with ${url} — ${err instanceof Error ? err.message : String(err)}`);
1605
+ }
1606
+
1607
+ console.log(`\n Confirm this code in your browser: ${c.bold(start.code)}\n`);
1608
+ console.log(` ${c.dim(start.verifyUrl)}\n`);
1609
+ const opened = flags["no-browser"] === true ? false : await openInBrowser(start.verifyUrl);
1610
+ console.log(` ${c.dim(opened ? "Opening the browser… waiting for you to approve." : "Open that address on any device and enter the code. Waiting…")}`);
1611
+
1612
+ const interval = Math.max(1, Number(start.interval) || 3) * 1000;
1613
+ const deadline = Date.parse(start.expiresAt) || Date.now() + 10 * 60 * 1000;
1614
+ while (Date.now() < deadline) {
1615
+ await sleep(interval);
1616
+ let poll;
1617
+ try {
1618
+ const res = await fetch(new URL(`/api/cli/login/${start.id}`, url));
1619
+ poll = await res.json().catch(() => ({}));
1620
+ } catch {
1621
+ continue; // a blip; the next poll asks again
1622
+ }
1623
+ if (poll.status === "pending") continue;
1624
+ if (poll.status === "denied") throw new Error("the sign-in was denied in the browser");
1625
+ if (poll.status === "expired") break;
1626
+ if (poll.status === "approved" && poll.token) {
1627
+ // `minted`: this key exists because of this login, so logout may end
1628
+ // it. A key stored with --token was made elsewhere and may be in use
1629
+ // elsewhere; logout only forgets it.
1630
+ saveCredential(url, { token: poll.token, email: poll.email, account: poll.account, role: poll.role, minted: true });
1631
+ console.log(`\n ${c.green("✓")} signed in to ${c.bold(url)} as ${poll.email} ${c.dim(`(${poll.account}, ${poll.role})`)}\n`);
1632
+ console.log(` ${c.dim("Stored in ~/.foldrun/credentials.json. `foldrun whoami` shows it; `foldrun logout` removes it.")}\n`);
1633
+ return 0;
1634
+ }
1635
+ }
1636
+ throw new Error("the code expired before it was approved — run `foldrun login` again");
1637
+ }
1638
+
1639
+ /**
1640
+ * `foldrun logout [--url]` — forget this machine's key, and, when `login`
1641
+ * minted it, revoke it on the platform too (an editor's key cannot revoke
1642
+ * keys; it is still forgotten here, and the Settings page can revoke it). A
1643
+ * key given with --token is only forgotten: it was made elsewhere and may
1644
+ * be in use elsewhere.
1645
+ */
1646
+ async function logout(flags) {
1647
+ const url = remoteUrl(flags);
1648
+ if (!url) {
1649
+ console.log(`\n ${c.dim("not signed in anywhere")}\n`);
1650
+ return 0;
1651
+ }
1652
+ const entry = credentialFor(url);
1653
+ if (!entry) {
1654
+ console.log(`\n ${c.dim(`not signed in to ${url}`)}\n`);
1655
+ return 0;
1656
+ }
1657
+ let revoked = false;
1658
+ if (entry.minted) {
1659
+ try {
1660
+ const me = await remoteCall(url, { token: entry.token }, "/api/me");
1661
+ if (me.actor?.kind === "key" && me.actor.id) {
1662
+ await remoteCall(url, { token: entry.token }, "/api/keys", { method: "DELETE", body: JSON.stringify({ id: me.actor.id }) });
1663
+ revoked = true;
1664
+ }
1665
+ } catch {
1666
+ // Not allowed, or unreachable. The local copy still goes.
1667
+ }
1668
+ }
1669
+ removeCredential(url);
1670
+ const note = revoked ? "" : entry.minted ? " (the key is forgotten here; revoke it on Settings → API keys to be sure)" : " (the key is forgotten here, not revoked — it was not made by `foldrun login`)";
1671
+ console.log(`\n ${c.green("✓")} signed out of ${c.bold(url)}${c.dim(note)}\n`);
1672
+ return 0;
1673
+ }
1674
+
1675
+ /** `foldrun whoami` — who the platform thinks this terminal is. */
1676
+ async function whoami(flags) {
1677
+ const url = remoteUrl(flags);
1678
+ if (!url) throw new Error(NOT_SIGNED_IN);
1679
+ const me = await remoteCall(url, flags, "/api/me");
1680
+ const who = me.actor.kind === "user" ? me.actor.email : `API key ${me.actor.prefix ?? ""}… ${c.dim(`"${me.actor.label ?? ""}"${me.actor.createdBy ? ` by ${me.actor.createdBy}` : ""}`)}`;
1681
+ console.log(`\n ${c.bold(who)}`);
1682
+ console.log(` platform ${url}`);
1683
+ console.log(` account ${me.account}${me.owner ? c.dim(` (owner ${me.owner})`) : ""}`);
1684
+ console.log(` role ${me.role}`);
1685
+ console.log(` workspaces ${me.workspaces === null ? "all" : me.workspaces.join(", ") || "none"}`);
1686
+ const source = flags.token ? "--token" : env("FOLDRUN_TOKEN") ? "FOLDRUN_TOKEN" : "~/.foldrun/credentials.json";
1687
+ console.log(` ${c.dim(`credential from ${source}`)}\n`);
1688
+ return 0;
1689
+ }
1690
+
1691
+ /**
1692
+ * `foldrun keys ls|create|revoke` — the account's API keys, from the terminal.
1693
+ *
1694
+ * keys ls every key, live and revoked
1695
+ * keys create <label> [--role r] an account key (editor unless said)
1696
+ * keys create <label> --for <ws> [--access read|write]
1697
+ * a deploy key: git clone/push for one workspace
1698
+ * keys revoke <id>
1699
+ *
1700
+ * Minting and revoking need admin, the same as the Settings page.
1701
+ */
1702
+ async function keysCmd(positional, flags) {
1703
+ const [verb, arg] = positional;
1704
+ const url = remoteUrl(flags);
1705
+ if (!url) throw new Error(NOT_SIGNED_IN);
1706
+
1707
+ if (verb === "ls" || verb === "list" || verb === undefined) {
1708
+ const { keys } = await remoteCall(url, flags, "/api/keys");
1709
+ if (!keys.length) {
1710
+ console.log(`\n ${c.dim("no API keys — foldrun keys create <label>")}\n`);
1711
+ return 0;
1712
+ }
1713
+ console.log("");
1714
+ for (const k of keys) {
1715
+ const what = k.scope ? `deploy · ${k.scope.workspace} (${k.scope.access})` : k.role ?? "admin";
1716
+ const state = k.revokedAt ? c.red("revoked") : c.green("live");
1717
+ console.log(` ${state.padEnd(20)} ${k.id} ${k.prefix}… ${c.bold(k.label)} ${c.dim(what)}${k.createdBy ? c.dim(` by ${k.createdBy}`) : ""} ${c.dim(k.createdAt.slice(0, 10))}`);
1718
+ }
1719
+ console.log("");
1720
+ return 0;
1721
+ }
1722
+ if (verb === "create" || verb === "new") {
1723
+ if (!arg) throw new Error("what is the key for? foldrun keys create <label>");
1724
+ const body = { label: arg };
1725
+ if (typeof flags.for === "string") body.workspace = flags.for;
1726
+ if (typeof flags.access === "string") body.access = flags.access;
1727
+ if (typeof flags.role === "string") body.role = flags.role;
1728
+ const made = await remoteCall(url, flags, "/api/keys", { method: "POST", body: JSON.stringify(body) });
1729
+ console.log(`\n ${c.green("✓")} ${c.bold(arg)} ${c.dim(made.id)}\n`);
1730
+ console.log(` ${made.key}\n`);
1731
+ console.log(` ${c.dim("Shown once. FOLDRUN_TOKEN=<key> uses it; `foldrun keys revoke " + made.id + "` ends it.")}\n`);
1732
+ return 0;
1733
+ }
1734
+ if (verb === "revoke" || verb === "rm") {
1735
+ if (!arg) throw new Error("which key? foldrun keys revoke <id> — ids are in `foldrun keys ls`");
1736
+ await remoteCall(url, flags, "/api/keys", { method: "DELETE", body: JSON.stringify({ id: arg }) });
1737
+ console.log(`\n ${c.green("✓")} revoked ${arg}\n`);
1738
+ return 0;
1739
+ }
1740
+ throw new Error(`keys: unknown verb "${verb}" — ls, create, revoke`);
1741
+ }
1742
+
1743
+ export async function run(command, positional, flags, workspace) {
1744
+ switch (command) {
1745
+ case "login":
1746
+ return login(flags);
1747
+ case "logout":
1748
+ return logout(flags);
1749
+ case "whoami":
1750
+ return whoami(flags);
1751
+ case "keys":
1752
+ return keysCmd(positional, flags);
1753
+ case "init":
1754
+ return init(workspace, flags.from);
1755
+ case "check":
1756
+ return check(workspace, flags);
1757
+ case "extract":
1758
+ return extract(workspace, flags);
1759
+ case "deploy":
1760
+ return deploy(positional[0] ?? ".", flags);
1761
+ case "run":
1762
+ return runTarget(positional[0], flags);
1763
+ case "eval":
1764
+ return runEvals(positional[0]);
1765
+ case "probe":
1766
+ return probeCmd(positional[0]);
1767
+ case "connect":
1768
+ return connect(positional, flags);
1769
+ case "secrets":
1770
+ return secretsCmd(positional, flags);
1771
+ case "logs":
1772
+ return logsCmd(positional, flags);
1773
+ case "invoke":
1774
+ return invoke(positional[0], flags);
1775
+ case "open":
1776
+ return openCmd(positional, flags);
1777
+ default:
1778
+ throw new Error(`unknown command "${command}" — try \`foldrun --help\``);
1779
+ }
1780
+ }