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.
- package/LICENSE +202 -0
- package/README.md +149 -0
- package/bin/foldrun.mjs +165 -0
- package/package.json +43 -0
- package/src/commands.mjs +1780 -0
- package/src/credentials.mjs +76 -0
package/src/commands.mjs
ADDED
|
@@ -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
|
+
}
|