foldrun 0.9.0 → 1.1.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/bin/foldrun.mjs +33 -11
- package/docs/agents.md +3 -1
- package/docs/api-usage.md +16 -2
- package/docs/api.md +30 -5
- package/docs/assistant.md +139 -0
- package/docs/backups-and-restore.md +35 -66
- package/docs/budgets-and-billing.md +1 -1
- package/docs/cli.md +23 -9
- package/docs/flows.md +1 -1
- package/docs/hooks.md +139 -0
- package/docs/network.md +158 -0
- package/docs/notifications.md +7 -1
- package/docs/secrets.md +54 -0
- package/docs/security.md +28 -7
- package/docs/tools.md +36 -5
- package/docs/versions.md +39 -2
- package/package.json +3 -3
- package/src/commands.mjs +904 -21
- package/src/guide.mjs +1 -1
- package/src/terminal.mjs +85 -0
package/src/commands.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// CLI commands. Kept separate from bin/ so the environment is set before the
|
|
2
2
|
// core is imported — single-workspace mode is read at module load.
|
|
3
3
|
|
|
4
|
+
import { writeExact } from "./terminal.mjs";
|
|
4
5
|
import dns from "node:dns";
|
|
5
6
|
import fs from "node:fs";
|
|
6
7
|
import crypto from "node:crypto";
|
|
@@ -710,8 +711,10 @@ async function platformLibrary(flags) {
|
|
|
710
711
|
return new Set((body.entries ?? []).map((e) => e.name).filter(Boolean));
|
|
711
712
|
};
|
|
712
713
|
try {
|
|
713
|
-
|
|
714
|
-
|
|
714
|
+
// Hooks are newer than the library route on some platforms: a platform
|
|
715
|
+
// that does not list them leaves the set empty rather than failing check.
|
|
716
|
+
const [tools, skills, hooks] = await Promise.all([names("tools"), names("skills"), names("hooks").catch(() => new Set())]);
|
|
717
|
+
return { url, tools, skills, hooks, warning: null };
|
|
715
718
|
} catch (err) {
|
|
716
719
|
return { url, tools: new Set(), skills: new Set(), warning: `could not read the library on ${url} (${err instanceof Error ? err.message : err}) — tools defined there will be reported missing` };
|
|
717
720
|
}
|
|
@@ -772,6 +775,11 @@ async function checkAccount(layout, flags) {
|
|
|
772
775
|
const problems = [];
|
|
773
776
|
const note = (level, where, message) => problems.push({ level, where, message });
|
|
774
777
|
validateSkills(path.join(layout.accountRoot, "library"), note);
|
|
778
|
+
const hc = await hooksCore();
|
|
779
|
+
for (const p of hc ? hc.checkHooksDir(path.join(layout.accountRoot, "library", "hooks"), "account") : []) {
|
|
780
|
+
const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
|
|
781
|
+
note("error", m ? m[1] : "hooks/", m ? m[2] : p);
|
|
782
|
+
}
|
|
775
783
|
const counts = LIBRARY_KINDS.map((k) => ({ kind: k, n: files.filter((f) => f.kind === k).length })).filter((x) => x.n > 0);
|
|
776
784
|
console.log(`\n ${c.bold("library")} ${c.dim(counts.length ? counts.map((x) => `${x.n} ${x.kind}`).join(" · ") : "empty")}`);
|
|
777
785
|
for (const p of problems) {
|
|
@@ -881,6 +889,10 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
881
889
|
const { readAgentsMd } = await core();
|
|
882
890
|
const { readLimits } = await import("@foldrun/core/limits");
|
|
883
891
|
for (const w of readLimits(readAgentsMd(workspace)?.data?.limits).problems) note("error", "AGENTS.md", w);
|
|
892
|
+
// `network:` here is every agent's default: a value nobody can read
|
|
893
|
+
// leaves them on the account's, or the platform's.
|
|
894
|
+
const net = await import("@foldrun/core/network").catch(() => null);
|
|
895
|
+
for (const w of net ? net.networkProblems(readAgentsMd(workspace)?.data?.network) : []) note("error", "AGENTS.md", w);
|
|
884
896
|
}
|
|
885
897
|
|
|
886
898
|
const { unopenedFrontmatter } = /** @type {any} */ (await core());
|
|
@@ -904,6 +916,9 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
904
916
|
// written into the file. The run would say so in its trail and carry
|
|
905
917
|
// on; the deploy refuses it; this is where a person hears it first.
|
|
906
918
|
for (const w of a.webProblems ?? []) note("error", `agents/${a.name}`, w);
|
|
919
|
+
// A network: value that cannot be read leaves the agent on the level
|
|
920
|
+
// above it — the deploy refuses it, so check says so first.
|
|
921
|
+
for (const w of a.networkProblems ?? []) note("error", `agents/${a.name}`, w);
|
|
907
922
|
// What works but should change: the old `session:` key, and a pinned
|
|
908
923
|
// user_agent whose Chrome is not the runner image's (the platform's
|
|
909
924
|
// /api/version says which Chrome; FOLDRUN_RUNNER_ENGINES on a server).
|
|
@@ -1053,6 +1068,8 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
1053
1068
|
}
|
|
1054
1069
|
}
|
|
1055
1070
|
|
|
1071
|
+
await checkHooks(workspace, agents, libraryDir(T), platform, note);
|
|
1072
|
+
|
|
1056
1073
|
// A script tool whose `run:` resolves to nothing parses, counts, and is
|
|
1057
1074
|
// offered to the agent — then fails inside a turn. Checking it here is the
|
|
1058
1075
|
// difference between a typo found in CI and a flow that quietly stops
|
|
@@ -1162,6 +1179,68 @@ async function checkProblems(workspace, flags = {}) {
|
|
|
1162
1179
|
return { problems, summary };
|
|
1163
1180
|
}
|
|
1164
1181
|
|
|
1182
|
+
/**
|
|
1183
|
+
* Hooks, checked with core's own reader so check and a run agree: a hook
|
|
1184
|
+
* folder that is not a hook, a `hooks:` name that resolves to nothing (a run
|
|
1185
|
+
* refuses that step rather than run it unguarded), and a tool.call hook on an
|
|
1186
|
+
* agent whose web search or fetch the model provider runs on its own servers
|
|
1187
|
+
* — those calls never pass foldrun's dispatch, so the hook never sees them.
|
|
1188
|
+
* A core from before hooks has no reader, and then there is nothing to say.
|
|
1189
|
+
*/
|
|
1190
|
+
async function checkHooks(workspace, agents, libraryRoot, platform, note) {
|
|
1191
|
+
const h = await hooksCore();
|
|
1192
|
+
if (!h) return;
|
|
1193
|
+
for (const p of h.checkHooksDir(path.join(workspace, "hooks"), "workspace")) {
|
|
1194
|
+
const m = /^(hooks\/[^/:]+(?:\/hook\.md)?): (.*)$/s.exec(p);
|
|
1195
|
+
note("error", m ? m[1] : "hooks/", m ? m[2] : p);
|
|
1196
|
+
}
|
|
1197
|
+
const matter = (await import("gray-matter")).default;
|
|
1198
|
+
const front = (file) => {
|
|
1199
|
+
try {
|
|
1200
|
+
return fs.existsSync(file) ? matter(fs.readFileSync(file, "utf8")).data ?? {} : {};
|
|
1201
|
+
} catch {
|
|
1202
|
+
return {}; // an unreadable file is reported by its own check
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
const exists = (n) =>
|
|
1206
|
+
fs.existsSync(path.join(workspace, "hooks", n)) || fs.existsSync(path.join(libraryRoot, "hooks", n)) || platform.hooks?.has(n);
|
|
1207
|
+
const missing = (where, names) => {
|
|
1208
|
+
for (const n of names) {
|
|
1209
|
+
if (!exists(n)) note("error", where, `hooks: [${n}] — no hooks/${n}/hook.md in this workspace or the account library`);
|
|
1210
|
+
}
|
|
1211
|
+
};
|
|
1212
|
+
const accountNames = h.hookNames(front(path.join(path.dirname(libraryRoot), "AGENTS.md")).hooks);
|
|
1213
|
+
const workspaceNames = h.hookNames(front(path.join(workspace, "AGENTS.md")).hooks);
|
|
1214
|
+
missing("account AGENTS.md", accountNames);
|
|
1215
|
+
missing("AGENTS.md", workspaceNames);
|
|
1216
|
+
|
|
1217
|
+
let resolveSearch = null;
|
|
1218
|
+
try {
|
|
1219
|
+
({ resolveSearch } = /** @type {any} */ (await import("@foldrun/core/providers")));
|
|
1220
|
+
} catch {
|
|
1221
|
+
// no provider table: the provider-side search warning is skipped
|
|
1222
|
+
}
|
|
1223
|
+
for (const a of agents) {
|
|
1224
|
+
const f = front(path.join(workspace, "agents", a.name, "agent.md"));
|
|
1225
|
+
const own = h.hookNames(f.hooks);
|
|
1226
|
+
missing(`agents/${a.name}`, own);
|
|
1227
|
+
if (!resolveSearch || !f.web || typeof f.web !== "object" || Array.isArray(f.web)) continue;
|
|
1228
|
+
const { hooks } = h.resolveHooks({ workspaceRoot: workspace, libraryRoot, names: [...accountNames, ...workspaceNames, ...own] });
|
|
1229
|
+
const gates = hooks.filter((x) => x.on === "tool.call").map((x) => x.name);
|
|
1230
|
+
if (!gates.length) continue;
|
|
1231
|
+
for (const action of ["search", "fetch"]) {
|
|
1232
|
+
if (f.web[action] === undefined) continue;
|
|
1233
|
+
const r = resolveSearch(f.web[action], action);
|
|
1234
|
+
if (!r?.provider || r.error || r.shape === "direct") continue;
|
|
1235
|
+
note(
|
|
1236
|
+
"warn",
|
|
1237
|
+
`agents/${a.name}`,
|
|
1238
|
+
`web.${action}: ${r.provider} runs on the model provider's servers, so tool.call hook${gates.length === 1 ? "" : "s"} ${gates.join(", ")} never see${gates.length === 1 ? "s" : ""} those calls — leave web.${action}: unset (or name a ${action} API) for foldrun's own dispatch, which hooks guard`,
|
|
1239
|
+
);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1165
1244
|
/** The web tool the platform's gallery serves: built in, and the only
|
|
1166
1245
|
* built-in that takes a provider. */
|
|
1167
1246
|
const WEB_TOOL = "web";
|
|
@@ -1907,7 +1986,47 @@ export function semverNewer(a, b) {
|
|
|
1907
1986
|
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i] > y.n[i];
|
|
1908
1987
|
if (x.pre && !y.pre) return false;
|
|
1909
1988
|
if (!x.pre && y.pre) return true;
|
|
1910
|
-
|
|
1989
|
+
if (!x.pre) return false;
|
|
1990
|
+
// SemVer 2.0.0 §11: dot-separated identifiers compared one by one — numbers
|
|
1991
|
+
// as numbers (rc.10 > rc.9), a number before a word, and a shorter list
|
|
1992
|
+
// before a longer one it begins.
|
|
1993
|
+
const xs = x.pre.slice(1).split(".");
|
|
1994
|
+
const ys = y.pre.slice(1).split(".");
|
|
1995
|
+
for (let i = 0; i < Math.max(xs.length, ys.length); i++) {
|
|
1996
|
+
if (xs[i] == null || ys[i] == null) return ys[i] == null;
|
|
1997
|
+
const xn = /^\d+$/.test(xs[i]);
|
|
1998
|
+
const yn = /^\d+$/.test(ys[i]);
|
|
1999
|
+
if (xn && yn) {
|
|
2000
|
+
if (Number(xs[i]) !== Number(ys[i])) return Number(xs[i]) > Number(ys[i]);
|
|
2001
|
+
} else if (xn !== yn) return yn;
|
|
2002
|
+
else if (xs[i] !== ys[i]) return xs[i] > ys[i];
|
|
2003
|
+
}
|
|
2004
|
+
return false;
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
/**
|
|
2008
|
+
* Is `latest` an update worth telling someone running `current` about? Newer,
|
|
2009
|
+
* and not a pre-release offered to someone on a stable release: npm keeps
|
|
2010
|
+
* pre-releases off `latest`, so `npm i -g foldrun@latest` would not even
|
|
2011
|
+
* install it.
|
|
2012
|
+
* @param {string} latest @param {string} current
|
|
2013
|
+
*/
|
|
2014
|
+
export function updateTo(latest, current) {
|
|
2015
|
+
// The whole header must be a version: it is printed, and a platform that
|
|
2016
|
+
// could append to it could write to the terminal.
|
|
2017
|
+
if (!/^v?\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?$/.test(String(latest ?? "").trim())) return false;
|
|
2018
|
+
const pre = (v) => /^v?\d+\.\d+\.\d+-/.test(String(v ?? "").trim());
|
|
2019
|
+
return semverNewer(latest, current) && !(pre(latest) && !pre(current));
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
/**
|
|
2023
|
+
* Text from a platform, made safe to print: no escape sequences or other
|
|
2024
|
+
* control characters, so a hostile or broken server cannot move the cursor,
|
|
2025
|
+
* retitle the terminal, write the clipboard (OSC 52) or disguise a link.
|
|
2026
|
+
* @param {unknown} s
|
|
2027
|
+
*/
|
|
2028
|
+
export function printable(s) {
|
|
2029
|
+
return String(s ?? "").replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
|
|
1911
2030
|
}
|
|
1912
2031
|
|
|
1913
2032
|
const UPDATE_EVERY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -1927,14 +2046,14 @@ function updateNotice(res) {
|
|
|
1927
2046
|
if (env("FOLDRUN_NO_UPDATE_NOTICE")) return;
|
|
1928
2047
|
if (!(process.stderr.isTTY || env("FOLDRUN_FORCE_TTY") === "1")) return;
|
|
1929
2048
|
const latest = res.headers.get("x-foldrun-cli");
|
|
1930
|
-
if (!latest || !
|
|
2049
|
+
if (!latest || !updateTo(latest, CLI_VERSION)) return;
|
|
1931
2050
|
noticeDone = true;
|
|
1932
2051
|
const file = path.join(credentialsDir(), "version-check.json");
|
|
1933
2052
|
try {
|
|
1934
2053
|
const last = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
1935
2054
|
if (Date.now() - Date.parse(last.notifiedAt) < UPDATE_EVERY_MS) return;
|
|
1936
2055
|
} catch {}
|
|
1937
|
-
const platform = res.headers.get("x-foldrun-version");
|
|
2056
|
+
const platform = printable(res.headers.get("x-foldrun-version") ?? "") || null;
|
|
1938
2057
|
console.error(` ${c.dim(`foldrun ${latest} is out (this is ${CLI_VERSION})${platform ? `, released with platform ${platform}` : ""} — npm i -g foldrun@latest`)}`);
|
|
1939
2058
|
fs.mkdirSync(credentialsDir(), { recursive: true });
|
|
1940
2059
|
fs.writeFileSync(file, JSON.stringify({ notifiedAt: new Date().toISOString(), cli: latest, platform }) + "\n");
|
|
@@ -1968,7 +2087,7 @@ async function versionCmd(flags) {
|
|
|
1968
2087
|
}
|
|
1969
2088
|
}
|
|
1970
2089
|
const latestCli = platform?.packages?.cli ?? null;
|
|
1971
|
-
const behind = latestCli ?
|
|
2090
|
+
const behind = latestCli ? updateTo(latestCli, CLI_VERSION) : false;
|
|
1972
2091
|
if (flags.json === true) {
|
|
1973
2092
|
console.log(JSON.stringify({ cli: CLI_VERSION, core, platform: platform ?? (url ? { url, error: unreachable } : null) }, null, 2));
|
|
1974
2093
|
if (behind) console.error(`foldrun ${latestCli} is out (this is ${CLI_VERSION}) — npm i -g foldrun@latest`);
|
|
@@ -2686,6 +2805,19 @@ async function readStorage(page, origin) {
|
|
|
2686
2805
|
.catch(() => empty);
|
|
2687
2806
|
}
|
|
2688
2807
|
|
|
2808
|
+
/** Bind on this machine's vault — a core from before site binding says so. */
|
|
2809
|
+
async function localSetSites(name, sites, scope) {
|
|
2810
|
+
const k = await core();
|
|
2811
|
+
if (typeof k.setSecretSites !== "function") throw new Error("binding a secret to sites needs @foldrun/core 1.1 or later — update foldrun");
|
|
2812
|
+
return k.setSecretSites("default", name, sites, scope);
|
|
2813
|
+
}
|
|
2814
|
+
|
|
2815
|
+
/** `--site a.com --site "*.b.org"` or `--site a.com,b.org`, as one list. */
|
|
2816
|
+
function siteFlags(flags) {
|
|
2817
|
+
const raw = flags.site === undefined ? [] : Array.isArray(flags.site) ? flags.site : [flags.site];
|
|
2818
|
+
return raw.filter((v) => typeof v === "string").flatMap((v) => v.split(",")).map((v) => v.trim()).filter(Boolean);
|
|
2819
|
+
}
|
|
2820
|
+
|
|
2689
2821
|
async function secretsCmd(positional, flags, layout) {
|
|
2690
2822
|
// --local: this machine's store even when signed in, the same escape
|
|
2691
2823
|
// hatch deploy and keys have. Without it, a signed-in laptop sent every
|
|
@@ -2716,7 +2848,7 @@ async function secretsCmd(positional, flags, layout) {
|
|
|
2716
2848
|
console.log();
|
|
2717
2849
|
for (const s of entries) {
|
|
2718
2850
|
console.log(
|
|
2719
|
-
` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}`)}`,
|
|
2851
|
+
` ${c.bold(s.name)} ${c.dim(`${s.scope}${s.shadowed ? " · shadowed" : ""} · ${s.updatedAt ?? ""}${s.sites?.length ? ` · sent only to ${s.sites.join(", ")}` : ""}`)}`,
|
|
2720
2852
|
);
|
|
2721
2853
|
}
|
|
2722
2854
|
console.log();
|
|
@@ -2751,6 +2883,31 @@ async function secretsCmd(positional, flags, layout) {
|
|
|
2751
2883
|
|
|
2752
2884
|
if (!name) throw new Error(`which secret? try \`foldrun secrets ${verb} NAME\``);
|
|
2753
2885
|
|
|
2886
|
+
// A secret bound to sites never enters a sandbox: steps hold `${NAME}` and
|
|
2887
|
+
// the egress proxy fills it in requests to those sites alone.
|
|
2888
|
+
if (verb === "sites") {
|
|
2889
|
+
const given = [...positional.slice(2), ...siteFlags(flags)];
|
|
2890
|
+
if (!given.length && flags.clear !== true) {
|
|
2891
|
+
const entries = url
|
|
2892
|
+
? (await remoteCall(url, flags, `/api/secrets${scope ? `?workspace=${encodeURIComponent(scope)}` : ""}`)).secrets
|
|
2893
|
+
: (await core()).listSecrets("default", scope);
|
|
2894
|
+
const hit = entries.find((s) => s.name === name);
|
|
2895
|
+
if (!hit) throw new Error(`no secret ${name}${scope ? ` in workspace ${scope}` : " in the account"}`);
|
|
2896
|
+
console.log(hit.sites?.length
|
|
2897
|
+
? `\n ${c.bold(name)} is sent only to ${hit.sites.join(", ")} — on the platform it never enters a sandbox\n`
|
|
2898
|
+
: `\n ${c.bold(name)} is not bound to sites — a step with a script or Bash gets its real value. Bind it: foldrun secrets sites ${name} api.example.com\n`);
|
|
2899
|
+
return 0;
|
|
2900
|
+
}
|
|
2901
|
+
const sites = flags.clear === true ? [] : given;
|
|
2902
|
+
const stored = url
|
|
2903
|
+
? (await remoteCall(url, flags, "/api/secrets", { method: "PATCH", body: JSON.stringify({ name, sites, workspace: scope }) })).sites
|
|
2904
|
+
: await localSetSites(name, sites, scope);
|
|
2905
|
+
console.log(stored.length
|
|
2906
|
+
? `\n ${c.green("✓")} ${name} is sent only to ${stored.join(", ")} — on the platform the egress proxy fills it, and it never enters a sandbox\n`
|
|
2907
|
+
: `\n ${c.green("✓")} ${name} unbound — a step with a script or Bash gets its real value again\n`);
|
|
2908
|
+
return 0;
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2754
2911
|
if (verb === "set") {
|
|
2755
2912
|
// --kind: the other shapes the Connections form stores — a file, an SSH
|
|
2756
2913
|
// login, API headers, a service account's key, a client-credentials app.
|
|
@@ -2786,15 +2943,18 @@ async function secretsCmd(positional, flags, layout) {
|
|
|
2786
2943
|
const value =
|
|
2787
2944
|
typeof flags.value === "string" ? flags.value : await promptHidden(` value for ${name}: `);
|
|
2788
2945
|
if (!value) throw new Error("empty value — nothing stored");
|
|
2946
|
+
const sites = siteFlags(flags);
|
|
2789
2947
|
if (url) {
|
|
2790
2948
|
await remoteCall(url, flags, "/api/secrets", {
|
|
2791
2949
|
method: "PUT",
|
|
2792
|
-
body: JSON.stringify({ name, value, workspace: scope }),
|
|
2950
|
+
body: JSON.stringify({ name, value, workspace: scope, ...(sites.length ? { sites } : {}) }),
|
|
2793
2951
|
});
|
|
2794
2952
|
} else {
|
|
2795
|
-
|
|
2953
|
+
const k = await core();
|
|
2954
|
+
k.setSecret("default", name, value, scope);
|
|
2955
|
+
if (sites.length) await localSetSites(name, sites, scope);
|
|
2796
2956
|
}
|
|
2797
|
-
console.log(`\n ${c.green("✓")} ${name} stored — declare it in agent.md under \`secrets:\` to use it\n`);
|
|
2957
|
+
console.log(`\n ${c.green("✓")} ${name} stored${sites.length ? ` and sent only to ${sites.join(", ")}` : ""} — declare it in agent.md under \`secrets:\` to use it\n`);
|
|
2798
2958
|
return 0;
|
|
2799
2959
|
}
|
|
2800
2960
|
|
|
@@ -3625,7 +3785,7 @@ async function auditCmd(positional, flags) {
|
|
|
3625
3785
|
q.set("format", "csv");
|
|
3626
3786
|
const { bytes, name } = await remoteBytes(url, flags, `/api/audit?${q}`, "the audit log");
|
|
3627
3787
|
if (typeof flags.file !== "string") {
|
|
3628
|
-
|
|
3788
|
+
writeExact(bytes);
|
|
3629
3789
|
return 0;
|
|
3630
3790
|
}
|
|
3631
3791
|
return writeDownload(bytes, name ?? "audit.csv", flags, "the audit log");
|
|
@@ -3701,7 +3861,7 @@ async function sourceCmd(positional, flags) {
|
|
|
3701
3861
|
if (verb === "cat" || verb === "read" || verb === "get") {
|
|
3702
3862
|
if (!a) throw new Error("which file? foldrun source cat flows/daily.md --to <workspace>");
|
|
3703
3863
|
const { content } = await remoteCall(url, flags, `${base}?path=${encodeURIComponent(a)}`);
|
|
3704
|
-
|
|
3864
|
+
writeExact(content.endsWith("\n") ? content : `${content}\n`);
|
|
3705
3865
|
return 0;
|
|
3706
3866
|
}
|
|
3707
3867
|
if (verb === "put" || verb === "write") {
|
|
@@ -4706,7 +4866,7 @@ async function storageCmd(positional, flags, layout) {
|
|
|
4706
4866
|
if (bytes.includes(0)) {
|
|
4707
4867
|
throw new Error(`${rel} is not text (${humanBytes(bytes.length)}) — \`foldrun storage cat ${rel} --preview\` reads what is in it, \`foldrun storage get ${rel}\` writes it to a file`);
|
|
4708
4868
|
}
|
|
4709
|
-
|
|
4869
|
+
writeExact(bytes);
|
|
4710
4870
|
return 0;
|
|
4711
4871
|
}
|
|
4712
4872
|
|
|
@@ -6122,6 +6282,308 @@ async function checkRemote(flags, layout) {
|
|
|
6122
6282
|
}
|
|
6123
6283
|
}
|
|
6124
6284
|
|
|
6285
|
+
// ---------------------------------------------------------------- hooks
|
|
6286
|
+
|
|
6287
|
+
/**
|
|
6288
|
+
* Core's hooks module — readHookDef, runHook, the event list — or null on a
|
|
6289
|
+
* core from before hooks, where `check` then says nothing about them and the
|
|
6290
|
+
* `hook` verbs say which core they need.
|
|
6291
|
+
*/
|
|
6292
|
+
async function hooksCore() {
|
|
6293
|
+
try {
|
|
6294
|
+
return /** @type {any} */ (await import("@foldrun/core/hooks"));
|
|
6295
|
+
} catch {
|
|
6296
|
+
return null;
|
|
6297
|
+
}
|
|
6298
|
+
}
|
|
6299
|
+
async function needHooksCore() {
|
|
6300
|
+
const h = await hooksCore();
|
|
6301
|
+
if (!h) throw new Error("this CLI's @foldrun/core has no hooks — update it (npm i -g foldrun@latest)");
|
|
6302
|
+
return h;
|
|
6303
|
+
}
|
|
6304
|
+
|
|
6305
|
+
/** The account library a hook verb works on: the folder beside workspaces/. */
|
|
6306
|
+
const libraryRootOf = (layout) => path.join(layout.accountRoot, "library");
|
|
6307
|
+
|
|
6308
|
+
/** What a new run.mjs answers, per event: a gate allows, a watcher notes nothing. */
|
|
6309
|
+
const HOOK_ANSWER = {
|
|
6310
|
+
"tool.call": `{ decision: "allow" }`,
|
|
6311
|
+
"tool.result": `{}`,
|
|
6312
|
+
"step.start": `{}`,
|
|
6313
|
+
"step.end": `{}`,
|
|
6314
|
+
};
|
|
6315
|
+
|
|
6316
|
+
/** A sample event per kind, for `hook test` with no --event. */
|
|
6317
|
+
const HOOK_SAMPLE = {
|
|
6318
|
+
"tool.call": { tool: { name: "Bash", input: { command: "ls" } } },
|
|
6319
|
+
"tool.result": { tool: { name: "Bash", input: { command: "ls" }, result: "agent.md\n" } },
|
|
6320
|
+
"step.start": { prompt: "Find three sources." },
|
|
6321
|
+
"step.end": { reply: "Done." },
|
|
6322
|
+
};
|
|
6323
|
+
|
|
6324
|
+
function hookRunTemplate(name, on) {
|
|
6325
|
+
return `// ${name} — a foldrun hook (on: ${on}). Self-contained: Node's built-ins only,
|
|
6326
|
+
// no relative imports — it runs from a copy taken when the step starts.
|
|
6327
|
+
//
|
|
6328
|
+
// In: one JSON object on stdin — the event:
|
|
6329
|
+
// { event, hook, run_id, step, agent, workspace,
|
|
6330
|
+
// tool: { name, input } (tool.call, tool.result; + result on tool.result)
|
|
6331
|
+
// prompt (step.start)
|
|
6332
|
+
// reply (step.end) }
|
|
6333
|
+
// Out: one JSON object on stdout, the last line printed:
|
|
6334
|
+
// tool.call { decision: "allow" | "deny", reason, input, note }
|
|
6335
|
+
// input replaces the tool's whole input; a deny needs a reason
|
|
6336
|
+
// step.start { context, note } context is added to the step's prompt
|
|
6337
|
+
// step.end { reply, note } reply replaces the step's reply
|
|
6338
|
+
// tool.result { note } a line on the run's trail
|
|
6339
|
+
// Exit 0 with an answer. Exit 2 denies, with stderr as the reason.
|
|
6340
|
+
// Anything else is a failure: a gate (tool.call, step.start) that fails
|
|
6341
|
+
// refuses; a watcher (tool.result, step.end) that fails is recorded.
|
|
6342
|
+
// No secrets and no model key reach this process.
|
|
6343
|
+
|
|
6344
|
+
let raw = "";
|
|
6345
|
+
for await (const chunk of process.stdin) raw += chunk;
|
|
6346
|
+
const event = JSON.parse(raw || "{}");
|
|
6347
|
+
|
|
6348
|
+
// Decide here. This one allows everything.
|
|
6349
|
+
void event;
|
|
6350
|
+
console.log(JSON.stringify(${HOOK_ANSWER[on]}));
|
|
6351
|
+
`;
|
|
6352
|
+
}
|
|
6353
|
+
|
|
6354
|
+
/** `foldrun hook <ls|add|test|rm>` — hooks in this workspace and the library. */
|
|
6355
|
+
async function hookCmd(positional, flags, layout) {
|
|
6356
|
+
const [verb = "ls"] = positional;
|
|
6357
|
+
if (verb === "ls" || verb === "list") return hookLsCmd(flags, layout);
|
|
6358
|
+
if (verb === "add" || verb === "new") return hookAddCmd(positional, flags, layout);
|
|
6359
|
+
if (verb === "test") return hookTestCmd(positional, flags, layout);
|
|
6360
|
+
if (verb === "rm") return hookRmCmd(positional, flags, layout);
|
|
6361
|
+
throw new Error(`unknown hook verb "${verb}" — ls, add <name> --on <event>, test <name>, rm <name>`);
|
|
6362
|
+
}
|
|
6363
|
+
|
|
6364
|
+
/** Every hook folder here, the workspace's first, each read the way a run reads it. */
|
|
6365
|
+
async function hooksHere(flags, layout) {
|
|
6366
|
+
const h = await needHooksCore();
|
|
6367
|
+
const ws = workspaceDirFor(flags, layout);
|
|
6368
|
+
const out = [];
|
|
6369
|
+
const seen = new Set();
|
|
6370
|
+
for (const [root, scope] of [[ws, "workspace"], [libraryRootOf(layout), "account"]]) {
|
|
6371
|
+
for (const dir of h.listHookDirs(path.join(root, "hooks"))) {
|
|
6372
|
+
const name = path.basename(dir);
|
|
6373
|
+
const def = h.readHookDef(dir, scope);
|
|
6374
|
+
const row = "error" in def
|
|
6375
|
+
? { name, scope, error: def.error }
|
|
6376
|
+
: { name, scope, on: def.on, tool: def.tool ?? null, timeout: def.timeoutSec, description: def.description ?? null };
|
|
6377
|
+
// A workspace hook of the same name is the one a run resolves.
|
|
6378
|
+
out.push({ ...row, ...(seen.has(name) ? { shadowed: true } : {}) });
|
|
6379
|
+
seen.add(name);
|
|
6380
|
+
}
|
|
6381
|
+
}
|
|
6382
|
+
return { h, ws, hooks: out };
|
|
6383
|
+
}
|
|
6384
|
+
|
|
6385
|
+
/** `hook ls --to <ws>`: the DEPLOYED workspace's hooks and what they did
|
|
6386
|
+
* lately — GET /api/workspaces/<ws>/hooks, the dashboard's Hooks panel. */
|
|
6387
|
+
async function hookLsRemote(flags) {
|
|
6388
|
+
const url = platformFor(flags, "hook ls");
|
|
6389
|
+
const ws = String(flags.to);
|
|
6390
|
+
const q = new URLSearchParams();
|
|
6391
|
+
if (flags.all === true) q.set("all", "1");
|
|
6392
|
+
if (flags.runs !== undefined) q.set("runs", String(flags.runs));
|
|
6393
|
+
const r = /** @type {{ hooks: any[], recent: any[] }} */ (await remoteCall(url, flags, `/api/workspaces/${encodeURIComponent(ws)}/hooks${q.size ? `?${q}` : ""}`));
|
|
6394
|
+
if (flags.json === true) {
|
|
6395
|
+
console.log(JSON.stringify(r, null, 2));
|
|
6396
|
+
return 0;
|
|
6397
|
+
}
|
|
6398
|
+
const hooks = r.hooks ?? [];
|
|
6399
|
+
if (!hooks.length) console.log(`\n ${c.dim(`no hooks in ${ws} or the account library`)}`);
|
|
6400
|
+
else {
|
|
6401
|
+
console.log();
|
|
6402
|
+
const w = Math.max(...hooks.map((x) => x.name.length));
|
|
6403
|
+
for (const x of hooks) {
|
|
6404
|
+
const where = x.scope === "account" ? "library" : "workspace";
|
|
6405
|
+
if (x.error) {
|
|
6406
|
+
console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
|
|
6407
|
+
continue;
|
|
6408
|
+
}
|
|
6409
|
+
const on = (x.onFor ?? []).length ? `on for ${x.onFor.join(", ")}` : "turned on by nobody";
|
|
6410
|
+
const note = x.shadowed ? "(the workspace's own of this name is the one that runs)" : `${on}${x.description ? ` — ${x.description}` : ""}`;
|
|
6411
|
+
console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${String(x.on).padEnd(11)} ${String(x.tool ?? "*").padEnd(12)} ${c.dim(note)}`);
|
|
6412
|
+
}
|
|
6413
|
+
}
|
|
6414
|
+
const recent = r.recent ?? [];
|
|
6415
|
+
console.log(`\n ${c.bold("lately")} ${c.dim(recent.length ? `${recent.length} ${flags.all === true ? "hook decision" : "deny, rewrite or failure"}${recent.length === 1 ? "" : "s"}, newest first` : flags.all === true ? "no hook ran" : "nothing denied, rewritten or failed (--all for every decision)")}`);
|
|
6416
|
+
for (const e of recent.slice(0, 20)) {
|
|
6417
|
+
const rec = e.record ?? {};
|
|
6418
|
+
console.log(` ${c.dim(String(e.t).slice(0, 16).replace("T", " "))} ${rec.hook} ${rec.did} ${rec.event}${rec.tool ? ` ${rec.tool}` : ""} ${c.dim(`${e.runId} step ${e.step} ${e.agent}`)}${rec.reason ? ` — ${firstLine(rec.reason, 80)}` : ""}`);
|
|
6419
|
+
}
|
|
6420
|
+
console.log(`\n ${c.dim("--runs <n> reads further back (default 50) · --all for every decision · --json")}\n`);
|
|
6421
|
+
return hooks.some((x) => x.error) ? 1 : 0;
|
|
6422
|
+
}
|
|
6423
|
+
|
|
6424
|
+
async function hookLsCmd(flags, layout) {
|
|
6425
|
+
if (typeof flags.to === "string") return hookLsRemote(flags);
|
|
6426
|
+
const { hooks } = await hooksHere(flags, layout);
|
|
6427
|
+
if (flags.json === true) {
|
|
6428
|
+
console.log(JSON.stringify(hooks, null, 2));
|
|
6429
|
+
return 0;
|
|
6430
|
+
}
|
|
6431
|
+
if (!hooks.length) {
|
|
6432
|
+
console.log(`\n ${c.dim("no hooks — `foldrun hook add <name> --on tool.call` makes one")}\n`);
|
|
6433
|
+
return 0;
|
|
6434
|
+
}
|
|
6435
|
+
console.log();
|
|
6436
|
+
const w = Math.max(...hooks.map((x) => x.name.length));
|
|
6437
|
+
for (const x of hooks) {
|
|
6438
|
+
const where = x.scope === "account" ? "library" : "workspace";
|
|
6439
|
+
if (x.error) {
|
|
6440
|
+
console.log(` ${c.red("✗")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${x.error}`);
|
|
6441
|
+
continue;
|
|
6442
|
+
}
|
|
6443
|
+
const what = `${x.on.padEnd(11)} ${(x.tool ?? "*").padEnd(12)}`;
|
|
6444
|
+
const note = x.shadowed ? c.dim("(the workspace's own of this name is the one that runs)") : c.dim(x.description ?? "");
|
|
6445
|
+
console.log(` ${c.green("✓")} ${c.bold(x.name.padEnd(w))} ${c.dim(where.padEnd(9))} ${what} ${note}`);
|
|
6446
|
+
}
|
|
6447
|
+
console.log(`\n ${c.dim("an agent turns one on with hooks: [name]; AGENTS.md hooks: turns it on for every agent under it")}\n`);
|
|
6448
|
+
return hooks.some((x) => x.error) ? 1 : 0;
|
|
6449
|
+
}
|
|
6450
|
+
|
|
6451
|
+
async function hookAddCmd(positional, flags, layout) {
|
|
6452
|
+
const h = await needHooksCore();
|
|
6453
|
+
const name = positional[1];
|
|
6454
|
+
if (!name) throw new Error("which hook? `foldrun hook add <name> --on tool.call|tool.result|step.start|step.end`");
|
|
6455
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name)) {
|
|
6456
|
+
throw new Error(`"${name}" is not a hook name — lowercase letters, digits and dashes, e.g. no-rm-rf`);
|
|
6457
|
+
}
|
|
6458
|
+
const on = flags.on;
|
|
6459
|
+
if (typeof on !== "string" || !h.HOOK_EVENTS.includes(on)) {
|
|
6460
|
+
throw new Error(`--on takes ${h.HOOK_EVENTS.join(", ")}${typeof on === "string" ? ` — not "${on}"` : ""}`);
|
|
6461
|
+
}
|
|
6462
|
+
const tool = typeof flags.tool === "string" ? flags.tool.trim() : undefined;
|
|
6463
|
+
if (flags.tool !== undefined && !tool) throw new Error("--tool takes a tool name or pattern, like Bash, Write|Edit or mcp__*");
|
|
6464
|
+
if (tool && on !== "tool.call" && on !== "tool.result") {
|
|
6465
|
+
throw new Error(`--tool only means something on tool.call and tool.result, not ${on}`);
|
|
6466
|
+
}
|
|
6467
|
+
const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
|
|
6468
|
+
const rel = `hooks/${name}`;
|
|
6469
|
+
const front = [
|
|
6470
|
+
"---",
|
|
6471
|
+
`on: ${on}`,
|
|
6472
|
+
...(tool ? [`tool: ${/[|*:]/.test(tool) ? JSON.stringify(tool) : tool}`] : []),
|
|
6473
|
+
`description: What ${name} checks, in one line.`,
|
|
6474
|
+
"---",
|
|
6475
|
+
"",
|
|
6476
|
+
`What ${name} is for, and what it refuses or rewrites. The code is run.mjs beside this file.`,
|
|
6477
|
+
"",
|
|
6478
|
+
].join("\n");
|
|
6479
|
+
writeFiles(root, [
|
|
6480
|
+
{ path: `${rel}/hook.md`, content: front },
|
|
6481
|
+
{ path: `${rel}/run.mjs`, content: hookRunTemplate(name, on) },
|
|
6482
|
+
], flags.library === true ? "the library" : path.basename(root));
|
|
6483
|
+
const where = path.relative(process.cwd(), path.join(root, rel)) || rel;
|
|
6484
|
+
console.log(`\n ${c.green("created")} hook ${c.bold(name)} ${c.dim(`(${on}${tool ? ` · ${tool}` : ""})`)} in ${where}\n`);
|
|
6485
|
+
console.log(` ${c.dim(`${rel}/hook.md`)}\n ${c.dim(`${rel}/run.mjs`)}`);
|
|
6486
|
+
console.log(`\n ${c.bold("Next")}
|
|
6487
|
+
edit ${path.join(where, "run.mjs")}
|
|
6488
|
+
hooks: [${name}]${" ".repeat(4)}${c.dim("in an agent's frontmatter — or in AGENTS.md for every agent")}
|
|
6489
|
+
foldrun hook test ${name}${" ".repeat(4)}${c.dim("run it on a sample event, here")}\n`);
|
|
6490
|
+
return 0;
|
|
6491
|
+
}
|
|
6492
|
+
|
|
6493
|
+
/** --event: inline JSON, or @file. */
|
|
6494
|
+
function readHookEvent(raw, on) {
|
|
6495
|
+
if (raw === undefined) return { event: HOOK_SAMPLE[on] ?? {}, sample: true };
|
|
6496
|
+
if (typeof raw !== "string") throw new Error("--event takes a JSON object, or @file");
|
|
6497
|
+
const text = raw.startsWith("@") ? fs.readFileSync(raw.slice(1), "utf8") : raw;
|
|
6498
|
+
let event;
|
|
6499
|
+
try { event = JSON.parse(text); } catch (err) {
|
|
6500
|
+
throw new Error(`--event is not JSON (${err instanceof Error ? err.message : err})`);
|
|
6501
|
+
}
|
|
6502
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) throw new Error("--event is one JSON object — the event the hook reads on stdin");
|
|
6503
|
+
return { event, sample: false };
|
|
6504
|
+
}
|
|
6505
|
+
|
|
6506
|
+
function printHookAnswer(name, on, a, where) {
|
|
6507
|
+
const mark = !a.ok ? c.red("✗") : a.decision === "deny" ? c.amber("■") : c.green("✓");
|
|
6508
|
+
console.log(`\n ${mark} ${c.bold(name)} ${c.dim(`${on ?? "?"} · ${where} · ${a.ms ?? 0}ms`)}`);
|
|
6509
|
+
if (!a.ok) console.log(` ${c.red("failed")} ${a.failure ?? "no reason given"}${on === "tool.call" || on === "step.start" ? c.dim(" — a gate that fails refuses") : ""}`);
|
|
6510
|
+
if (a.decision) console.log(` ${pad("decision", 9)} ${a.decision === "deny" ? c.amber("deny") : "allow"}`);
|
|
6511
|
+
if (a.reason) console.log(` ${pad("reason", 9)} ${a.reason}`);
|
|
6512
|
+
if (a.input) console.log(` ${pad("input", 9)} ${JSON.stringify(a.input)}`);
|
|
6513
|
+
if (a.context) console.log(` ${pad("context", 9)} ${a.context}`);
|
|
6514
|
+
if (a.reply !== undefined) console.log(` ${pad("reply", 9)} ${a.reply}`);
|
|
6515
|
+
if (a.note) console.log(` ${pad("note", 9)} ${a.note}`);
|
|
6516
|
+
console.log();
|
|
6517
|
+
return a.ok ? 0 : 1;
|
|
6518
|
+
}
|
|
6519
|
+
|
|
6520
|
+
/**
|
|
6521
|
+
* `foldrun hook test <name> [--event '<json>']` — run one hook on one event,
|
|
6522
|
+
* here, the way a step runs it (core's runHook: the event on stdin, a
|
|
6523
|
+
* stripped environment, its timeout). `--to <workspace>` asks the platform
|
|
6524
|
+
* to run the deployed one instead.
|
|
6525
|
+
*/
|
|
6526
|
+
async function hookTestCmd(positional, flags, layout) {
|
|
6527
|
+
const name = positional[1];
|
|
6528
|
+
if (!name) throw new Error("which hook? `foldrun hook test <name> --event '<json>'` — `foldrun hook ls` lists them");
|
|
6529
|
+
if (typeof flags.to === "string") {
|
|
6530
|
+
const url = platformFor(flags, "hook test");
|
|
6531
|
+
const ws = flags.to;
|
|
6532
|
+
let event = {};
|
|
6533
|
+
if (flags.event !== undefined) event = readHookEvent(flags.event, "tool.call").event;
|
|
6534
|
+
const r = /** @type {any} */ (await remoteCall(
|
|
6535
|
+
url,
|
|
6536
|
+
flags,
|
|
6537
|
+
`/api/workspaces/${enc(ws)}/hooks/${enc(name)}/test`,
|
|
6538
|
+
{ method: "POST", body: JSON.stringify({ event }) },
|
|
6539
|
+
));
|
|
6540
|
+
const a = r?.answer ?? r ?? {};
|
|
6541
|
+
return printHookAnswer(name, r?.on ?? a.on, { ok: a.ok !== false, ...a }, ws);
|
|
6542
|
+
}
|
|
6543
|
+
const h = await needHooksCore();
|
|
6544
|
+
const ws = workspaceDirFor({ ...flags, to: undefined }, layout);
|
|
6545
|
+
const r = h.resolveHooks({ workspaceRoot: ws, libraryRoot: libraryRootOf(layout), names: [name] });
|
|
6546
|
+
if (r.missing.length) throw new Error(`no hook named ${name} in hooks/ or the library — \`foldrun hook ls\` lists them`);
|
|
6547
|
+
if (r.errors.length) throw new Error(r.errors[0]);
|
|
6548
|
+
const def = r.hooks[0];
|
|
6549
|
+
const { event, sample } = readHookEvent(flags.event, def.on);
|
|
6550
|
+
if (sample) console.log(`\n ${c.dim(`no --event, so a sample ${def.on} event: ${JSON.stringify(event)}`)}`);
|
|
6551
|
+
const full = { event: def.on, hook: def.name, run_id: null, step: null, agent: null, workspace: path.basename(ws), ...event };
|
|
6552
|
+
const a = await h.runHook(def, full, { cwd: ws, env: process.env });
|
|
6553
|
+
return printHookAnswer(name, def.on, a, def.scope === "account" ? "library" : path.basename(ws));
|
|
6554
|
+
}
|
|
6555
|
+
|
|
6556
|
+
async function hookRmCmd(positional, flags, layout) {
|
|
6557
|
+
const h = await needHooksCore();
|
|
6558
|
+
const name = positional[1];
|
|
6559
|
+
if (!name) throw new Error("which hook? `foldrun hook rm <name>` (--library for the account's)");
|
|
6560
|
+
const root = flags.library === true ? libraryRootOf(layout) : workspaceDirFor(flags, layout);
|
|
6561
|
+
const dir = path.join(root, "hooks", name);
|
|
6562
|
+
if (!/^[a-z0-9][a-z0-9-]{0,63}$/.test(name) || !fs.existsSync(dir)) {
|
|
6563
|
+
throw new Error(`no hooks/${name} in ${flags.library === true ? "the library" : path.basename(root)} — \`foldrun hook ls\` lists them`);
|
|
6564
|
+
}
|
|
6565
|
+
// Who still turns it on: they fail check, and their steps are refused.
|
|
6566
|
+
const users = [];
|
|
6567
|
+
const wsRoots = flags.library === true
|
|
6568
|
+
? (layout.workspacesDir ? layout.workspaces.map((w) => path.join(layout.workspacesDir, w)) : [layout.workspaceDir].filter(Boolean))
|
|
6569
|
+
: [root];
|
|
6570
|
+
const matter = (await import("gray-matter")).default;
|
|
6571
|
+
const names = (file) => {
|
|
6572
|
+
try { return h.hookNames(matter(fs.readFileSync(file, "utf8")).data?.hooks); } catch { return []; }
|
|
6573
|
+
};
|
|
6574
|
+
for (const w of wsRoots) {
|
|
6575
|
+
if (names(path.join(w, "AGENTS.md")).includes(name)) users.push(`${path.basename(w)}/AGENTS.md`);
|
|
6576
|
+
for (const a of ls(path.join(w, "agents"))) {
|
|
6577
|
+
if (names(path.join(w, "agents", a, "agent.md")).includes(name)) users.push(`${path.basename(w)}/agents/${a}`);
|
|
6578
|
+
}
|
|
6579
|
+
}
|
|
6580
|
+
const still = users.length ? ` — still turned on by ${users.join(", ")}, whose steps will be refused until it goes from their hooks:` : "";
|
|
6581
|
+
if (!(await sureToDelete(flags, "hook rm", `${flags.library === true ? "library/" : ""}hooks/${name}${still}`))) return 1;
|
|
6582
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
6583
|
+
console.log(`\n ${c.green("✓")} removed hooks/${name}${users.length ? c.dim(` — take it out of: ${users.join(", ")}`) : ""}\n`);
|
|
6584
|
+
return 0;
|
|
6585
|
+
}
|
|
6586
|
+
|
|
6125
6587
|
// ------------------------------------------- flows and teams, as the canvas edits them
|
|
6126
6588
|
//
|
|
6127
6589
|
// The dashboard's flow canvas turns every palette block into a small, exact
|
|
@@ -6829,6 +7291,84 @@ async function agentLinkCmd(positional, flags, layout) {
|
|
|
6829
7291
|
return 0;
|
|
6830
7292
|
}
|
|
6831
7293
|
|
|
7294
|
+
/**
|
|
7295
|
+
* `foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]…
|
|
7296
|
+
* [--remove <site>]…` — which sites the agent's sandbox may reach, its
|
|
7297
|
+
* `network:` line, edited the way the dashboard's Network card edits it
|
|
7298
|
+
* (core network.ts setNetworkSetting: that one line, nothing else). With no
|
|
7299
|
+
* level and no site it prints what a run resolves.
|
|
7300
|
+
*/
|
|
7301
|
+
async function agentNetworkCmd(positional, flags, layout) {
|
|
7302
|
+
const name = positional[1];
|
|
7303
|
+
const word = positional[2];
|
|
7304
|
+
const usage = "`foldrun agent network <agent> [open|trusted|none|inherit] [--add <site>]… [--remove <site>]…`";
|
|
7305
|
+
if (!name) throw new Error(usage);
|
|
7306
|
+
if (word !== undefined && !["open", "trusted", "none", "inherit"].includes(word)) throw new Error(`${usage} — "${word}" is not a level`);
|
|
7307
|
+
const net = await import("@foldrun/core/network").catch(() => null);
|
|
7308
|
+
if (!net) throw new Error("agent network needs @foldrun/core 1.1 or later — update foldrun");
|
|
7309
|
+
const desk = await openDesk(flags, layout, `agent network ${name}`);
|
|
7310
|
+
const rel = `agents/${name}/agent.md`;
|
|
7311
|
+
if (!desk.files.has(rel)) {
|
|
7312
|
+
const agents = [...desk.files.keys()].map((r) => /^agents\/([^/]+)\/agent\.md$/.exec(r)?.[1]).filter(Boolean);
|
|
7313
|
+
throw new Error(`no agent "${name}" in ${desk.label} — it has ${agents.join(", ") || "none"}`);
|
|
7314
|
+
}
|
|
7315
|
+
const resolve = (files) =>
|
|
7316
|
+
inTree(desk, files, async (dir) => {
|
|
7317
|
+
const { listAgents, readFrontmatter } = await core();
|
|
7318
|
+
return {
|
|
7319
|
+
info: listAgents("default", "workspace").find((a) => a.name === name),
|
|
7320
|
+
own: net.readNetwork(readFrontmatter(path.join(dir, rel)).network).setting,
|
|
7321
|
+
};
|
|
7322
|
+
});
|
|
7323
|
+
const show = (info) => {
|
|
7324
|
+
const p = info?.network;
|
|
7325
|
+
if (!p) return;
|
|
7326
|
+
console.log(` ${net.networkLine(p, true)}`);
|
|
7327
|
+
if (p.level !== "open") console.log(` ${c.dim("a run also allows its MCP servers, its web providers and the sites its secrets are bound to")}`);
|
|
7328
|
+
};
|
|
7329
|
+
const raw = desk.files.get(rel);
|
|
7330
|
+
const now = await resolve(desk.files);
|
|
7331
|
+
const list = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]).filter((x) => typeof x === "string").flatMap((x) => x.split(",")).map((x) => x.trim().toLowerCase()).filter(Boolean);
|
|
7332
|
+
const adds = list(flags.add);
|
|
7333
|
+
const removes = list(flags.remove);
|
|
7334
|
+
if (word === undefined && !adds.length && !removes.length) {
|
|
7335
|
+
console.log(`\n ${c.bold(name)} ${c.dim(`· ${desk.label}`)}`);
|
|
7336
|
+
show(now.info);
|
|
7337
|
+
console.log();
|
|
7338
|
+
return 0;
|
|
7339
|
+
}
|
|
7340
|
+
const own = now.own ?? { allow: [] };
|
|
7341
|
+
const level = word === "inherit" ? undefined : word ?? own.level;
|
|
7342
|
+
const allow = [...new Set([...own.allow, ...adds])].filter((x) => !removes.includes(x));
|
|
7343
|
+
const setting = level || allow.length || own.defaults === false
|
|
7344
|
+
? { ...(level ? { level } : {}), allow, ...(own.defaults === false ? { defaults: false } : {}) }
|
|
7345
|
+
: null;
|
|
7346
|
+
const problems = setting ? net.readNetwork(setting).problems : [];
|
|
7347
|
+
if (problems.length) throw new Error(problems.join(" "));
|
|
7348
|
+
const next = net.setNetworkSetting(raw, setting);
|
|
7349
|
+
if (next === raw) {
|
|
7350
|
+
console.log(`\n ${c.dim(`${name}'s network: already says that — nothing to change`)}\n`);
|
|
7351
|
+
return 0;
|
|
7352
|
+
}
|
|
7353
|
+
console.log(`\n ${c.bold(name)} ${c.dim(`· network · ${desk.label}`)}\n`);
|
|
7354
|
+
await printDiff(rel, raw, next);
|
|
7355
|
+
if (flags["dry-run"] === true) {
|
|
7356
|
+
console.log(`\n ${c.dim("--dry-run: nothing written")}\n`);
|
|
7357
|
+
return 0;
|
|
7358
|
+
}
|
|
7359
|
+
if (desk.platform) {
|
|
7360
|
+
await remoteCall(desk.url, flags, `/api/workspaces/${enc(desk.ws)}/agents/${enc(name)}`, { method: "PATCH", body: JSON.stringify({ network: setting }) });
|
|
7361
|
+
} else {
|
|
7362
|
+
assertUnchanged(desk, rel, raw);
|
|
7363
|
+
writeIfUnchanged(desk, rel, raw, next);
|
|
7364
|
+
}
|
|
7365
|
+
const after = await resolve(new Map([...desk.files, [rel, next]]));
|
|
7366
|
+
console.log();
|
|
7367
|
+
show(after.info);
|
|
7368
|
+
console.log(`\n ${c.green("✓")} ${rel} ${c.dim(desk.platform ? `in ${desk.ws} — a revision on the platform` : "— `foldrun deploy` ships it")}\n`);
|
|
7369
|
+
return 0;
|
|
7370
|
+
}
|
|
7371
|
+
|
|
6832
7372
|
/**
|
|
6833
7373
|
* `foldrun flow show <flow>` — the canvas, in a terminal: the trigger, one
|
|
6834
7374
|
* block per group, each step's chips, the team under its agent, and what
|
|
@@ -7138,7 +7678,7 @@ async function runRmCmd(runId, flags, layout) {
|
|
|
7138
7678
|
/** Where downloaded bytes go: --file (or - for stdout), else a default name. Refuses to clobber. */
|
|
7139
7679
|
function writeDownload(bytes, fallback, flags, from) {
|
|
7140
7680
|
if (flags.file === "-") {
|
|
7141
|
-
|
|
7681
|
+
writeExact(bytes);
|
|
7142
7682
|
return 0;
|
|
7143
7683
|
}
|
|
7144
7684
|
const to = typeof flags.file === "string" ? flags.file : fallback;
|
|
@@ -7174,7 +7714,7 @@ async function apiCmd(positional, flags) {
|
|
|
7174
7714
|
const out = typeof flags.out === "string" ? flags.out : typeof flags.file === "string" ? flags.file : "-";
|
|
7175
7715
|
const pretty = JSON.stringify(doc, null, 2) + "\n";
|
|
7176
7716
|
if (out === "-") {
|
|
7177
|
-
|
|
7717
|
+
writeExact(pretty);
|
|
7178
7718
|
return 0;
|
|
7179
7719
|
}
|
|
7180
7720
|
fs.writeFileSync(out, pretty);
|
|
@@ -7461,7 +8001,7 @@ async function billingVerbCmd(positional, flags) {
|
|
|
7461
8001
|
if (flags.csv === true) {
|
|
7462
8002
|
const { bytes, name } = await remoteBytes(url, flags, `/api/billing/statement?month=${month}&format=csv`, `statement ${month}`);
|
|
7463
8003
|
if (typeof flags.file !== "string") {
|
|
7464
|
-
|
|
8004
|
+
writeExact(bytes);
|
|
7465
8005
|
return 0;
|
|
7466
8006
|
}
|
|
7467
8007
|
return writeDownload(bytes, name ?? `foldrun-${month}.csv`, flags, `the ${month} statement`);
|
|
@@ -8441,6 +8981,295 @@ async function flowDraftCmd(positional, flags, layout) {
|
|
|
8441
8981
|
return 0;
|
|
8442
8982
|
}
|
|
8443
8983
|
|
|
8984
|
+
/** `foldrun assistant history` — your kept conversations, newest first
|
|
8985
|
+
* (`--q` searches them, `--workspace` narrows to one, `--json`). */
|
|
8986
|
+
async function assistantHistoryCmd(url, flags) {
|
|
8987
|
+
const q = new URLSearchParams();
|
|
8988
|
+
if (typeof flags.q === "string" && flags.q.trim()) q.set("q", flags.q.trim());
|
|
8989
|
+
if (typeof flags.workspace === "string") q.set("workspace", flags.workspace);
|
|
8990
|
+
if (typeof flags.limit === "string") q.set("limit", flags.limit);
|
|
8991
|
+
const d = await remoteCall(url, flags, `/api/assistant/conversations${q.size ? `?${q}` : ""}`);
|
|
8992
|
+
const list = d.conversations ?? [];
|
|
8993
|
+
if (flags.json) {
|
|
8994
|
+
console.log(JSON.stringify(d, null, 2));
|
|
8995
|
+
return 0;
|
|
8996
|
+
}
|
|
8997
|
+
if (!list.length) {
|
|
8998
|
+
console.log(`\n ${c.dim(flags.q ? "nothing matches" : "no conversations yet")}\n`);
|
|
8999
|
+
return 0;
|
|
9000
|
+
}
|
|
9001
|
+
console.log();
|
|
9002
|
+
for (const x of list) {
|
|
9003
|
+
console.log(` ${c.bold(x.id)} ${x.workspace} ${c.dim(`${ago(x.updatedAt)} ago · ${x.messages} message${x.messages === 1 ? "" : "s"}${x.unsaved ? ` · ${x.unsaved} unsaved` : ""}`)}`);
|
|
9004
|
+
console.log(` ${x.title}`);
|
|
9005
|
+
}
|
|
9006
|
+
console.log(`\n ${c.dim("foldrun assistant show <id> · foldrun assistant --conversation <id> \"…\" · foldrun assistant rm <id>")}\n`);
|
|
9007
|
+
return 0;
|
|
9008
|
+
}
|
|
9009
|
+
|
|
9010
|
+
/** `foldrun assistant show <id>` — one kept conversation, whole. */
|
|
9011
|
+
async function assistantShowCmd(url, flags, id) {
|
|
9012
|
+
const d = await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`);
|
|
9013
|
+
if (flags.json) {
|
|
9014
|
+
console.log(JSON.stringify(d, null, 2));
|
|
9015
|
+
return 0;
|
|
9016
|
+
}
|
|
9017
|
+
console.log(`\n ${c.bold(d.title)} ${c.dim(`${d.workspace} · ${ago(d.updatedAt)} ago`)}\n`);
|
|
9018
|
+
for (const m of d.messages ?? []) {
|
|
9019
|
+
console.log(` ${m.role === "user" ? c.bold("you ›") : c.dim("assistant ›")}`);
|
|
9020
|
+
console.log(`${String(m.content).split("\n").map((l) => ` ${l}`).join("\n")}\n`);
|
|
9021
|
+
}
|
|
9022
|
+
if ((d.files ?? []).length) console.log(` ${c.dim(`${d.files.length} proposed file${d.files.length === 1 ? "" : "s"} not saved: ${d.files.map((f) => f.path).join(", ")}`)}\n`);
|
|
9023
|
+
return 0;
|
|
9024
|
+
}
|
|
9025
|
+
|
|
9026
|
+
/** `foldrun assistant rm <id>` — delete one kept conversation (asks; --yes). */
|
|
9027
|
+
async function assistantRmCmd(url, flags, id) {
|
|
9028
|
+
if (!(await sureToDelete(flags, "assistant rm", `Delete the conversation ${id}`))) return 0;
|
|
9029
|
+
await remoteCall(url, flags, `/api/assistant/conversations/${enc(id)}`, { method: "DELETE" });
|
|
9030
|
+
console.log(` ${c.green("✓")} deleted ${id}\n`);
|
|
9031
|
+
return 0;
|
|
9032
|
+
}
|
|
9033
|
+
|
|
9034
|
+
/**
|
|
9035
|
+
* `foldrun assistant ["<message>"] --to <ws>` (also `foldrun ai`) — the
|
|
9036
|
+
* dashboard's assistant from a terminal: it answers from foldrun's docs and
|
|
9037
|
+
* the deployed workspace's files only, and writes agents, flows, tools and
|
|
9038
|
+
* skills as proposals (POST …/assistant). With a message it answers once;
|
|
9039
|
+
* with none, in a terminal, it is a conversation (/save, /files, /drop
|
|
9040
|
+
* <path>, /new, /quit). Proposals are shown as diffs with what the check
|
|
9041
|
+
* finds and saved only on yes (--yes; --dry-run never), each through
|
|
9042
|
+
* `PUT …/source` with ifMatch, so a file that changed meanwhile is refused.
|
|
9043
|
+
* --json prints the platform's answer as it is.
|
|
9044
|
+
*/
|
|
9045
|
+
async function assistantCmd(positional, flags, layout) {
|
|
9046
|
+
const url = platformFor(flags, "assistant");
|
|
9047
|
+
// The kept conversations: `history`, `show <id>`, `rm <id>`.
|
|
9048
|
+
const sub = positional[0];
|
|
9049
|
+
if (sub === "history" && positional.length === 1) return assistantHistoryCmd(url, flags);
|
|
9050
|
+
if ((sub === "show" || sub === "rm") && positional.length === 2 && /^c_[a-z0-9]{8,40}$/.test(positional[1])) {
|
|
9051
|
+
return sub === "show" ? assistantShowCmd(url, flags, positional[1]) : assistantRmCmd(url, flags, positional[1]);
|
|
9052
|
+
}
|
|
9053
|
+
// A kept conversation knows its workspace; --to may still say another.
|
|
9054
|
+
let ws = typeof flags.conversation === "string" && typeof flags.to !== "string" ? "" : platformWorkspace(flags, layout, "assistant");
|
|
9055
|
+
const first = positional.join(" ").trim();
|
|
9056
|
+
/** A new workspace the last reply proposed.
|
|
9057
|
+
* @type {{ name: string, purpose: string } | null} */
|
|
9058
|
+
let proposedWs = null;
|
|
9059
|
+
if (!first && !process.stdin.isTTY) {
|
|
9060
|
+
throw new Error('`foldrun assistant "<question or what to build>" --to <workspace>` — with no message it needs a terminal to talk in');
|
|
9061
|
+
}
|
|
9062
|
+
/** @type {{ role: "user" | "assistant", content: string }[]} */
|
|
9063
|
+
let messages = [];
|
|
9064
|
+
/** @type {{ path: string, content: string, before: string | null, sha256: string | null }[]} */
|
|
9065
|
+
let files = [];
|
|
9066
|
+
/** @type {{ where: string, message: string, level: string }[]} */
|
|
9067
|
+
let issues = [];
|
|
9068
|
+
/** The kept conversation this one continues; null until the first reply. */
|
|
9069
|
+
let conversation = typeof flags.conversation === "string" ? flags.conversation : null;
|
|
9070
|
+
if (conversation) {
|
|
9071
|
+
// Carry a kept one on: its messages and its unsaved files come back.
|
|
9072
|
+
const kept = await remoteCall(url, flags, `/api/assistant/conversations/${enc(conversation)}`);
|
|
9073
|
+
messages = (kept.messages ?? []).map((m) => ({ role: m.role, content: m.content }));
|
|
9074
|
+
files = kept.files ?? [];
|
|
9075
|
+
if (!ws) ws = kept.workspace;
|
|
9076
|
+
if (!flags.json) console.log(`\n ${c.dim(`carrying on "${kept.title}" in ${kept.workspace} — ${messages.length} message${messages.length === 1 ? "" : "s"}`)}`);
|
|
9077
|
+
}
|
|
9078
|
+
|
|
9079
|
+
const showFiles = async () => {
|
|
9080
|
+
for (const f of files) {
|
|
9081
|
+
console.log(` ${c.dim(f.before === null ? "new" : "replaces")}`);
|
|
9082
|
+
await printDiff(f.path, f.before ?? "", f.content);
|
|
9083
|
+
console.log();
|
|
9084
|
+
}
|
|
9085
|
+
for (const i of issues) console.log(` ${i.level === "error" ? c.red("error") : c.amber(" warn")} ${c.bold(i.where)} ${i.message}`);
|
|
9086
|
+
if (issues.length) console.log();
|
|
9087
|
+
};
|
|
9088
|
+
|
|
9089
|
+
const ask = async (text) => {
|
|
9090
|
+
messages.push({ role: "user", content: text });
|
|
9091
|
+
if (!flags.json) console.log(`\n ${c.dim("reading the docs and the workspace — one reply, charged like a step…")}`);
|
|
9092
|
+
let d;
|
|
9093
|
+
try {
|
|
9094
|
+
d = await remoteCall(
|
|
9095
|
+
url,
|
|
9096
|
+
flags,
|
|
9097
|
+
`/api/workspaces/${enc(ws)}/assistant`,
|
|
9098
|
+
{ method: "POST", body: JSON.stringify({ messages, files: files.map(({ path: p, content }) => ({ path: p, content })), ...(conversation ? { conversation } : {}) }) },
|
|
9099
|
+
{ seconds: Math.max(timeoutSeconds(flags), 300) },
|
|
9100
|
+
);
|
|
9101
|
+
} catch (err) {
|
|
9102
|
+
messages.pop();
|
|
9103
|
+
throw err;
|
|
9104
|
+
}
|
|
9105
|
+
messages.push({ role: "assistant", content: d.reply ?? "" });
|
|
9106
|
+
files = d.files ?? [];
|
|
9107
|
+
issues = d.issues ?? [];
|
|
9108
|
+
proposedWs = d.workspace ?? null;
|
|
9109
|
+
if (typeof d.conversation === "string" && d.conversation) conversation = d.conversation;
|
|
9110
|
+
if (flags.json) {
|
|
9111
|
+
console.log(JSON.stringify(d, null, 2));
|
|
9112
|
+
return;
|
|
9113
|
+
}
|
|
9114
|
+
console.log(`\n${(d.reply ?? "").split("\n").map((l) => ` ${l}`).join("\n")}\n`);
|
|
9115
|
+
if ((d.sources ?? []).length) console.log(` ${c.dim(`read: ${d.sources.map((s) => `${s.doc} › ${s.section}`).join(" · ")}`)}\n`);
|
|
9116
|
+
if (conversation) console.log(` ${c.dim(`kept as ${conversation} — carry on with --conversation ${conversation}`)}\n`);
|
|
9117
|
+
if (d.usage) {
|
|
9118
|
+
const k = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n));
|
|
9119
|
+
const paid = d.supply === "provider" ? "on your own model key" : typeof d.chargedUsd === "number" ? `$${d.chargedUsd.toFixed(d.chargedUsd > 0 && d.chargedUsd < 0.01 ? 4 : 2)} charged` : "";
|
|
9120
|
+
console.log(` ${c.dim(`${k(d.usage.inputTokens)} tokens in · ${k(d.usage.outputTokens)} out${paid ? ` · ${paid}` : ""}`)}\n`);
|
|
9121
|
+
}
|
|
9122
|
+
if (proposedWs) console.log(` ${c.bold("new workspace")} ${proposedWs.name} ${c.dim(`— ${proposedWs.purpose}`)}\n`);
|
|
9123
|
+
await showFiles();
|
|
9124
|
+
};
|
|
9125
|
+
|
|
9126
|
+
/** What the last reply proposed — read through a call, because ask()
|
|
9127
|
+
* sets it and a type checker narrowing the variable cannot see that. */
|
|
9128
|
+
const proposal = () => proposedWs;
|
|
9129
|
+
|
|
9130
|
+
/** Create the proposed workspace, blank, as the dashboard does — after a
|
|
9131
|
+
* yes, and never over one that exists (the same POST deploys over it).
|
|
9132
|
+
* @param {{ name: string, purpose: string }} p */
|
|
9133
|
+
const createProposed = async (p) => {
|
|
9134
|
+
const name = p.name;
|
|
9135
|
+
if (!(await confirmed(flags, "assistant", `Create the workspace ${name}? [y/N] `))) {
|
|
9136
|
+
console.log(`\n ${c.dim("not created")}\n`);
|
|
9137
|
+
return false;
|
|
9138
|
+
}
|
|
9139
|
+
// ifAbsent: the server refuses a name that exists (409) rather than
|
|
9140
|
+
// deploying over it — atomically, where a list-then-create could race.
|
|
9141
|
+
try {
|
|
9142
|
+
await remoteCall(url, flags, "/api/workspaces", { method: "POST", body: JSON.stringify({ name, template: true, ifAbsent: true }) });
|
|
9143
|
+
} catch (err) {
|
|
9144
|
+
if (err instanceof HttpError && err.status === 409) {
|
|
9145
|
+
console.log(` ${c.red("✗")} a workspace called ${name} exists — nothing created\n`);
|
|
9146
|
+
return false;
|
|
9147
|
+
}
|
|
9148
|
+
throw err;
|
|
9149
|
+
}
|
|
9150
|
+
console.log(` ${c.green("✓")} created ${name}\n`);
|
|
9151
|
+
proposedWs = null;
|
|
9152
|
+
return name;
|
|
9153
|
+
};
|
|
9154
|
+
|
|
9155
|
+
const save = async (note) => {
|
|
9156
|
+
if (!files.length) {
|
|
9157
|
+
console.log(` ${c.dim("nothing proposed to save")}\n`);
|
|
9158
|
+
return true;
|
|
9159
|
+
}
|
|
9160
|
+
if (!(await confirmed(flags, "assistant", `Save ${files.length} file${files.length === 1 ? "" : "s"} to ${ws}${files.some((f) => f.before !== null) ? " (replacing what is there)" : ""}? [y/N] `))) {
|
|
9161
|
+
console.log(`\n ${c.dim("nothing saved")}\n`);
|
|
9162
|
+
return true;
|
|
9163
|
+
}
|
|
9164
|
+
const message = `written with the assistant: ${note.replace(/\s+/g, " ").slice(0, 80)}`;
|
|
9165
|
+
for (const f of [...files]) {
|
|
9166
|
+
try {
|
|
9167
|
+
await remoteCall(url, flags, `/api/workspaces/${enc(ws)}/source`, { method: "PUT", body: JSON.stringify({ path: f.path, content: f.content, message, ifMatch: f.sha256 ?? null }) });
|
|
9168
|
+
console.log(` ${c.green("✓")} ${f.path}`);
|
|
9169
|
+
files = files.filter((x) => x.path !== f.path);
|
|
9170
|
+
issues = issues.filter((i) => !(i.where === f.path || i.where.startsWith(`${f.path}:`)));
|
|
9171
|
+
} catch (err) {
|
|
9172
|
+
console.log(` ${c.red("✗")} ${f.path} — ${err instanceof Error ? err.message : err}`);
|
|
9173
|
+
return false;
|
|
9174
|
+
}
|
|
9175
|
+
}
|
|
9176
|
+
console.log(`\n ${c.dim(`each is a revision — \`foldrun history --to ${ws}\` · \`foldrun check --to ${ws}\``)}\n`);
|
|
9177
|
+
return true;
|
|
9178
|
+
};
|
|
9179
|
+
|
|
9180
|
+
if (first) {
|
|
9181
|
+
await ask(first);
|
|
9182
|
+
const p = proposal();
|
|
9183
|
+
if (p && !flags.json) {
|
|
9184
|
+
if (flags["dry-run"] === true) {
|
|
9185
|
+
console.log(` ${c.dim("--dry-run: no workspace created")}\n`);
|
|
9186
|
+
return 0;
|
|
9187
|
+
}
|
|
9188
|
+
if (flags.yes !== true && !process.stdin.isTTY) {
|
|
9189
|
+
console.log(` ${c.dim(`not a terminal to ask — no workspace created; run it again with --yes, or create it with \`foldrun new ${p.name}\` and deploy`)}\n`);
|
|
9190
|
+
return 1;
|
|
9191
|
+
}
|
|
9192
|
+
const made = await createProposed(p);
|
|
9193
|
+
if (made) console.log(` ${c.dim(`carry on there: foldrun assistant --to ${made} "<what it should do>"`)}\n`);
|
|
9194
|
+
return made ? 0 : 1;
|
|
9195
|
+
}
|
|
9196
|
+
if (!files.length || flags.json) return 0;
|
|
9197
|
+
if (flags["dry-run"] === true) {
|
|
9198
|
+
console.log(` ${c.dim("--dry-run: nothing saved")}\n`);
|
|
9199
|
+
return 0;
|
|
9200
|
+
}
|
|
9201
|
+
if (flags.yes !== true && !process.stdin.isTTY) {
|
|
9202
|
+
console.log(` ${c.dim(`the reply was made (charged like a step), but this is not a terminal to ask — nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`}, or save them with \`foldrun source put\``)}\n`);
|
|
9203
|
+
return 1;
|
|
9204
|
+
}
|
|
9205
|
+
return (await save(first)) ? 0 : 1;
|
|
9206
|
+
}
|
|
9207
|
+
|
|
9208
|
+
const { createInterface } = await import("node:readline/promises");
|
|
9209
|
+
console.log(`\n ${c.bold("foldrun assistant")} ${c.dim(`· ${ws} · answers from the foldrun docs; nothing is saved until you /save`)}`);
|
|
9210
|
+
console.log(` ${c.dim("/save /files /drop <path> /new /quit")}\n`);
|
|
9211
|
+
for (;;) {
|
|
9212
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
9213
|
+
let line;
|
|
9214
|
+
try {
|
|
9215
|
+
line = (await rl.question(`${c.bold("you ›")} `)).trim();
|
|
9216
|
+
} catch {
|
|
9217
|
+
line = "/quit"; // the input closed (Ctrl-D)
|
|
9218
|
+
} finally {
|
|
9219
|
+
rl.close();
|
|
9220
|
+
}
|
|
9221
|
+
if (!line) continue;
|
|
9222
|
+
if (line === "/quit" || line === "/exit") {
|
|
9223
|
+
if (files.length) console.log(`\n ${c.dim(`${files.length} proposed file${files.length === 1 ? "" : "s"} not saved`)}`);
|
|
9224
|
+
console.log();
|
|
9225
|
+
return 0;
|
|
9226
|
+
}
|
|
9227
|
+
if (line === "/new") {
|
|
9228
|
+
messages = [];
|
|
9229
|
+
files = [];
|
|
9230
|
+
issues = [];
|
|
9231
|
+
conversation = null;
|
|
9232
|
+
console.log(` ${c.dim("a new conversation")}\n`);
|
|
9233
|
+
continue;
|
|
9234
|
+
}
|
|
9235
|
+
if (line === "/files") {
|
|
9236
|
+
if (files.length) await showFiles();
|
|
9237
|
+
else console.log(` ${c.dim("nothing proposed")}\n`);
|
|
9238
|
+
continue;
|
|
9239
|
+
}
|
|
9240
|
+
if (line.startsWith("/drop")) {
|
|
9241
|
+
const target = line.slice(5).trim();
|
|
9242
|
+
const before = files.length;
|
|
9243
|
+
files = files.filter((f) => f.path !== target);
|
|
9244
|
+
issues = issues.filter((i) => !(i.where === target || i.where.startsWith(`${target}:`)));
|
|
9245
|
+
console.log(` ${c.dim(files.length < before ? `dropped ${target}` : `${target || "(no path)"} is not proposed`)}\n`);
|
|
9246
|
+
continue;
|
|
9247
|
+
}
|
|
9248
|
+
if (line === "/save") {
|
|
9249
|
+
await save(messages.find((m) => m.role === "user")?.content ?? "");
|
|
9250
|
+
continue;
|
|
9251
|
+
}
|
|
9252
|
+
try {
|
|
9253
|
+
await ask(line);
|
|
9254
|
+
const p = proposal();
|
|
9255
|
+
if (p) {
|
|
9256
|
+
const made = await createProposed(p);
|
|
9257
|
+
if (made) {
|
|
9258
|
+
// The conversation moves into the new workspace and carries on.
|
|
9259
|
+
ws = made;
|
|
9260
|
+
files = [];
|
|
9261
|
+
issues = [];
|
|
9262
|
+
messages.push({ role: "assistant", content: `Created the workspace \`${made}\`.` });
|
|
9263
|
+
console.log(` ${c.dim(`now working in ${made}`)}`);
|
|
9264
|
+
await ask(`The workspace "${made}" now exists and you are working in it. Carry on with what I asked for: write its agents, flows, tools and skills.`);
|
|
9265
|
+
}
|
|
9266
|
+
}
|
|
9267
|
+
} catch (err) {
|
|
9268
|
+
console.log(`\n ${c.red("✗")} ${err instanceof Error ? err.message : err}\n`);
|
|
9269
|
+
}
|
|
9270
|
+
}
|
|
9271
|
+
}
|
|
9272
|
+
|
|
8444
9273
|
/**
|
|
8445
9274
|
* `foldrun history restore <path> --id <revision> --to <ws>` — the History
|
|
8446
9275
|
* drawer's "Restore this version": the file is rewritten with that
|
|
@@ -8495,7 +9324,7 @@ async function scheduleTickCmd(flags) {
|
|
|
8495
9324
|
return 0;
|
|
8496
9325
|
}
|
|
8497
9326
|
|
|
8498
|
-
const LIBRARY_KINDS_CLI = ["skills", "tools", "scripts", "knowledge", "memory"];
|
|
9327
|
+
const LIBRARY_KINDS_CLI = ["skills", "tools", "hooks", "scripts", "knowledge", "memory"];
|
|
8499
9328
|
|
|
8500
9329
|
/**
|
|
8501
9330
|
* `foldrun library [ls [kind]] | cat <kind>/<path> | put <kind>/<path> | rm
|
|
@@ -8515,9 +9344,16 @@ async function libraryCmd(positional, flags) {
|
|
|
8515
9344
|
if (verb === "ls" || verb === "list") {
|
|
8516
9345
|
const kinds = spec ? [spec] : LIBRARY_KINDS_CLI;
|
|
8517
9346
|
for (const k of kinds) if (!LIBRARY_KINDS_CLI.includes(k)) throw new Error(`no library kind "${k}" — ${LIBRARY_KINDS_CLI.join(", ")}`);
|
|
8518
|
-
|
|
9347
|
+
// Listing everything, a kind an older platform does not have yet (hooks
|
|
9348
|
+
// before 11 Oct 2026) is left out rather than failing the whole list;
|
|
9349
|
+
// asked for by name, it fails with the platform's own answer.
|
|
9350
|
+
const lists = await Promise.all(kinds.map((k) => remoteCall(url, flags, `/api/library/${k}`).catch((err) => {
|
|
9351
|
+
if (spec) throw err;
|
|
9352
|
+
return null;
|
|
9353
|
+
})));
|
|
8519
9354
|
console.log();
|
|
8520
9355
|
lists.forEach((l, n) => {
|
|
9356
|
+
if (!l) return;
|
|
8521
9357
|
const entries = l.entries ?? [];
|
|
8522
9358
|
console.log(` ${c.bold(kinds[n])} ${c.dim(`${entries.length}`)}`);
|
|
8523
9359
|
for (const e of entries) console.log(` ${pad(e.name ?? e.path ?? "", 28)} ${c.dim(firstLine(e.description ?? "", 70))}`);
|
|
@@ -8528,7 +9364,7 @@ async function libraryCmd(positional, flags) {
|
|
|
8528
9364
|
if (verb === "cat") {
|
|
8529
9365
|
const { kind, rel } = split(spec);
|
|
8530
9366
|
const { content } = await remoteCall(url, flags, `/api/library/${kind}?path=${enc(rel)}`);
|
|
8531
|
-
|
|
9367
|
+
writeExact(content ?? "");
|
|
8532
9368
|
return 0;
|
|
8533
9369
|
}
|
|
8534
9370
|
if (verb === "put") {
|
|
@@ -8797,10 +9633,29 @@ async function apiVersionCmd(positional, flags) {
|
|
|
8797
9633
|
console.log(`\n ${c.bold("API version")} ${v.effective} ${c.dim(v.pinned ? `pinned (current is ${v.current})` : "current — not pinned")}`);
|
|
8798
9634
|
console.log(` ${c.dim(`served: ${(v.supported ?? []).join(", ") || "—"} · each for ${v.supportMonths ?? "?"} months after the next · this CLI sends ${API_VERSION}`)}`);
|
|
8799
9635
|
if (l.perCredential) console.log(` ${c.bold("rate limits")} ${c.dim(`${l.plan} · per key ${l.perCredential.read} reads / ${l.perCredential.write} writes, per account ${l.perAccount?.read} / ${l.perAccount?.write} — every ${l.windowSeconds}s`)}`);
|
|
9636
|
+
apiUsageLines(facts.usage);
|
|
8800
9637
|
console.log(`\n ${c.dim("foldrun api version pin YYYY-MM-DD · unpin")}\n`);
|
|
8801
9638
|
return 0;
|
|
8802
9639
|
}
|
|
8803
9640
|
|
|
9641
|
+
/** The account's use of the API, last 30 days: requests by version, and any
|
|
9642
|
+
* deprecated route it still calls, by whom, and when it stops. An older
|
|
9643
|
+
* platform sends no `usage`; null means it could not be read. */
|
|
9644
|
+
function apiUsageLines(usage) {
|
|
9645
|
+
if (usage === undefined) return;
|
|
9646
|
+
if (usage === null) {
|
|
9647
|
+
console.log(` ${c.bold("your use")} ${c.dim("could not be read just now")}`);
|
|
9648
|
+
return;
|
|
9649
|
+
}
|
|
9650
|
+
const total = (usage.versions ?? []).reduce((s, v) => s + (v.requests ?? 0), 0);
|
|
9651
|
+
const day = (iso) => String(iso ?? "").slice(0, 10);
|
|
9652
|
+
console.log(` ${c.bold("your use")} ${c.dim(total ? `last ${usage.days} days: ` + usage.versions.map((v) => `${v.version} ${v.requests} (last ${day(v.lastSeen)})`).join(" · ") : `no API requests in the last ${usage.days} days`)}`);
|
|
9653
|
+
for (const d of usage.deprecated ?? []) {
|
|
9654
|
+
console.log(` ${c.amber("deprecated")} ${d.route} — ${d.requests} request${d.requests === 1 ? "" : "s"}, last ${day(d.lastSeen)}; ${d.sunsetAt ? `stops answering ${day(d.sunsetAt)}` : "no end date yet"}${d.link ? ` · ${d.link}` : ""}`);
|
|
9655
|
+
for (const who of d.callers ?? []) console.log(` ${c.dim(`${who.credential || "an unknown caller"}: ${who.requests}, last ${day(who.lastSeen)}`)}`);
|
|
9656
|
+
}
|
|
9657
|
+
}
|
|
9658
|
+
|
|
8804
9659
|
const THEMES_CLI = ["system", "light", "dark"];
|
|
8805
9660
|
|
|
8806
9661
|
/**
|
|
@@ -8828,7 +9683,8 @@ async function preferencesCmd(positional, flags) {
|
|
|
8828
9683
|
return 0;
|
|
8829
9684
|
}
|
|
8830
9685
|
|
|
8831
|
-
/** `foldrun changelog [--limit n]` — What's new:
|
|
9686
|
+
/** `foldrun changelog [--limit n]` — What's new: the API's versions, what each
|
|
9687
|
+
* broke and what is deprecated, then each release and what it changed. */
|
|
8832
9688
|
async function changelogCmd(flags) {
|
|
8833
9689
|
const url = platformFor(flags, "changelog");
|
|
8834
9690
|
const limit = Number(flags.limit) > 0 ? Math.min(50, Math.floor(Number(flags.limit))) : 5;
|
|
@@ -8837,6 +9693,7 @@ async function changelogCmd(flags) {
|
|
|
8837
9693
|
console.log(JSON.stringify(r, null, 2));
|
|
8838
9694
|
return 0;
|
|
8839
9695
|
}
|
|
9696
|
+
if (r.api) apiChangelogLines(r.api);
|
|
8840
9697
|
const releases = r.releases ?? [];
|
|
8841
9698
|
if (!releases.length) {
|
|
8842
9699
|
console.log(`\n ${c.dim(`no release notes on this install (it runs ${r.version ?? "an unreleased build"})`)}\n`);
|
|
@@ -8859,6 +9716,25 @@ async function changelogCmd(flags) {
|
|
|
8859
9716
|
return 0;
|
|
8860
9717
|
}
|
|
8861
9718
|
|
|
9719
|
+
/** The API part of What's new: the current version, the breaking changes by
|
|
9720
|
+
* version, and routes on their way out. Older platforms send no `api`. */
|
|
9721
|
+
function apiChangelogLines(api) {
|
|
9722
|
+
const p = printable;
|
|
9723
|
+
const broke = (api.versions ?? []).filter((v) => v.changes?.length);
|
|
9724
|
+
const deps = api.deprecations ?? [];
|
|
9725
|
+
console.log(`\n ${c.bold(`API ${p(api.current)}`)} ${c.dim(`each version works ${Number(api.supportMonths) || 12} months or more after the next · pin: foldrun api version pin <date>`)}`);
|
|
9726
|
+
if (!broke.length && !deps.length) {
|
|
9727
|
+
console.log(` ${c.dim("nothing existing code would break on, and nothing deprecated")}`);
|
|
9728
|
+
return;
|
|
9729
|
+
}
|
|
9730
|
+
if (broke.length) console.log(` ${c.dim("breaking changes")}`);
|
|
9731
|
+
for (const v of broke) for (const ch of v.changes) console.log(` · ${p(v.version)} ${firstLine(p(ch), 100)}`);
|
|
9732
|
+
if (deps.length) console.log(` ${c.dim("deprecated")}`);
|
|
9733
|
+
for (const d of deps) {
|
|
9734
|
+
console.log(` · ${p(d.route)} — since ${p(d.deprecatedAt)}, ${d.sunsetAt ? `stops answering ${p(d.sunsetAt)}` : "no end date yet"}${d.link ? ` · ${p(d.link)}` : ""}`);
|
|
9735
|
+
}
|
|
9736
|
+
}
|
|
9737
|
+
|
|
8862
9738
|
/**
|
|
8863
9739
|
* `foldrun find <query>` — the ⌘K search: workspaces, agents, flows, tools,
|
|
8864
9740
|
* skills, knowledge, memory and runs across the account, ranked as the
|
|
@@ -9065,7 +9941,7 @@ function printPreview(p, rel) {
|
|
|
9065
9941
|
case "text":
|
|
9066
9942
|
case "html":
|
|
9067
9943
|
case "svg":
|
|
9068
|
-
|
|
9944
|
+
writeExact(`${p.text ?? p.html ?? p.svg ?? ""}`.replace(/\n?$/, "\n"));
|
|
9069
9945
|
if (p.truncated) out(c.dim("… (truncated)"));
|
|
9070
9946
|
return;
|
|
9071
9947
|
case "table":
|
|
@@ -9308,11 +10184,15 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
9308
10184
|
// `new` scaffolds one here; `run` runs one there. The noun is the same
|
|
9309
10185
|
// thing in both cases, which is why they share a command.
|
|
9310
10186
|
if (positional[0] === "link" || positional[0] === "unlink") return agentLinkCmd(positional, flags, layout);
|
|
10187
|
+
if (positional[0] === "network") return agentNetworkCmd(positional, flags, layout);
|
|
9311
10188
|
if (positional[0] === "import") return agentImportCmd(positional, flags, layout);
|
|
9312
10189
|
if (positional[0] === "ls" || positional[0] === "list") return remoteListCmd("agents", flags, layout);
|
|
9313
10190
|
return positional[0] === "run"
|
|
9314
10191
|
? agentRunCmd(positional, flags, layout)
|
|
9315
10192
|
: scaffoldCmd("agents", positional, flags, layout);
|
|
10193
|
+
case "assistant":
|
|
10194
|
+
case "ai":
|
|
10195
|
+
return assistantCmd(positional, flags, layout);
|
|
9316
10196
|
case "flow":
|
|
9317
10197
|
// `new` scaffolds a file; `add`, `rm-step` and `show` are the canvas; `run` is
|
|
9318
10198
|
// `invoke`, spelled the way `agent run` is; `rotate-hook` a new URL.
|
|
@@ -9335,6 +10215,9 @@ export async function run(command, positional, flags, workspace, layout) {
|
|
|
9335
10215
|
return positional[0] === "test"
|
|
9336
10216
|
? toolTestCmd(positional, flags, layout)
|
|
9337
10217
|
: scaffoldCmd("tools", positional, flags, layout);
|
|
10218
|
+
case "hook":
|
|
10219
|
+
case "hooks":
|
|
10220
|
+
return hookCmd(positional, flags, layout);
|
|
9338
10221
|
case "invoke":
|
|
9339
10222
|
return invoke(positional[0], flags);
|
|
9340
10223
|
case "source":
|