foldrun 0.7.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,7 +13,7 @@ beyond the model call itself.
13
13
 
14
14
  ```sh
15
15
  npm install -g foldrun
16
- export ANTHROPIC_API_KEY=sk-ant-…
16
+ export ANTHROPIC_API_KEY=sk-ant-… # or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`
17
17
 
18
18
  foldrun init competitor-watch
19
19
  cd competitor-watch
package/bin/foldrun.mjs CHANGED
@@ -187,7 +187,7 @@ Signing in
187
187
  foldrun whoami who you are on the platform: account, role, workspaces
188
188
  foldrun onboarding the account's getting-started steps, done or not, the next one first (--json)
189
189
  foldrun preferences your own settings on the platform — the theme; set theme system|light|dark (a key acts for whoever minted it)
190
- foldrun doctor check the path to the platform: node, CLI, account, DNS, a timed /api/healthz
190
+ foldrun doctor check the path to the platform: node, CLI, model credential, account, DNS, a timed /api/healthz
191
191
  foldrun accounts every account signed in on this machine, and which one is active
192
192
  foldrun use <name> act as one of them from here on
193
193
  foldrun keys ls the account's API keys: created, last used, expires, scope — also create <label> [--expires 90d], rotate <id> [--grace 1h], revoke <id>
@@ -247,7 +247,7 @@ Options
247
247
  --new-client connect: enter a new OAuth client even when one is saved for the secret
248
248
  --step <n> approve, reject: decide only that step; default is every step that is waiting
249
249
  --note "<text>" approve, reject: guidance the agent reads — or the reason for a refusal
250
- --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, history restore, billing plan, repo, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
250
+ --yes approve, stop, rerun (bulk), flow rotate-hook, flow draft, history restore, restore <ws>, billing plan, repo, workspace set name, and every rm/revoke/unshare: skip the confirmation, deliberately (required with no terminal)
251
251
  --json report: the raw run record instead of the report; observe, usage, api version, changelog, find, preferences, workspace vocabulary: the raw document; version: {cli, core, platform}
252
252
  --events <a,b> account set notify: failed, awaiting-approval, completed
253
253
  --failed webhooks deliveries: only the ones that gave up
@@ -274,7 +274,7 @@ Options
274
274
  --grace <span> keys rotate: keep the old key working for 1h or 24h while you swap the new one in (default 0: revoked now)
275
275
 
276
276
  Platform options (deploy, invoke, secrets, logs, keys)
277
- --to <workspace> workspace on the platform (deploy default: folder name); flow add/show, agent link: edit or show the deployed copy instead of this folder
277
+ --to <workspace> workspace on the platform (deploy default: folder name); flow add/show, agent link: edit or show the deployed copy instead of this folder; restore <ws>: the commit, date or age to put it back to (3d, 2026-09-28T14:00)
278
278
  --tenant <name> account to deploy into (default: default, local only)
279
279
  --data <dir> the installation's data directory
280
280
  --url <url> a running platform (or FOLDRUN_URL, or where you last signed in)
@@ -285,7 +285,7 @@ Platform options (deploy, invoke, secrets, logs, keys)
285
285
  --quiet leave out the dim "acting as …" line a platform command prints on stderr
286
286
  --local deploy: into the installation on this machine, even when signed in
287
287
  --commit <sha> deploy: record which commit this is
288
- --dry-run deploy: check and report, change nothing; flow add, flow set, flow trigger, flow move-step, flow paste, flow draft, history restore, agent link, agent unlink: show the diff, write nothing; stop, rerun (bulk): list what matches, touch nothing
288
+ --dry-run deploy: check and report, change nothing; flow add, flow set, flow trigger, flow move-step, flow paste, flow draft, history restore, agent link, agent unlink: show the diff, write nothing; restore <ws>: the diff only, restore nothing; stop, rerun (bulk): list what matches, touch nothing
289
289
  --no-runtimes deploy: do not wait for the workspace's environments to be built
290
290
  --force deploy: deploy even while runs are in flight; pull, storage get: overwrite local files
291
291
  --yes deploy: allow deleting files this folder no longer has (asked otherwise; required with no terminal)
@@ -346,12 +346,21 @@ function helpFor(which) {
346
346
  if (!which) return HELP;
347
347
  const esc = which.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
348
348
  const usage = new RegExp(`^\\s*foldrun ${esc}(\\s|$)`);
349
- // "approve, reject: …" names approve too — a comma after the name is
350
- // part of a list of commands, not the end of it.
351
- const option = new RegExp(`(^|[;,]\\s*)${esc}([\\s,][^;:]*)?:`);
349
+ // An option's text is clauses split by ";", each "<commands>: what it
350
+ // does there". Only the part before a clause's first colon names
351
+ // commands, as a comma list — "approve, reject: …" names approve too.
352
+ // Matching anywhere up to a colon let prose in the text name a command:
353
+ // secrets' --kind reads "api (--base-url, --header "Name: value"…" and
354
+ // showed under `api --help`.
355
+ const item = new RegExp(`^${esc}(\\s|$)`);
356
+ const names = (text) =>
357
+ text.split(";").some((clause) => {
358
+ const colon = clause.indexOf(":");
359
+ return colon > 0 && clause.slice(0, colon).split(",").some((x) => item.test(x.trim()));
360
+ });
352
361
  const lines = HELP.split("\n");
353
362
  const own = lines.filter((l) => usage.test(l));
354
- const opts = lines.filter((l) => /^\s*--/.test(l) && option.test(l.replace(/^\s*--\S+(\s+(\/\s+)?--\S+)*(\s"?<[^>]+>"?)?\s+/, "")));
363
+ const opts = lines.filter((l) => /^\s*--/.test(l) && names(l.replace(/^\s*--\S+(\s+(\/\s+)?--\S+)*(\s"?<[^>]+>"?)?\s+/, "")));
355
364
  if (!own.length && !opts.length) return HELP;
356
365
  return ["", ...own, ...(opts.length ? ["", "Options:", ...opts] : []), "", "`foldrun --help` for everything."].join("\n");
357
366
  }
package/docs/cli.md CHANGED
@@ -1,11 +1,32 @@
1
1
  # CLI reference
2
2
 
3
- `foldrun` needs no account. `ANTHROPIC_API_KEY` — an API key from
4
- console.anthropic.com — is enough to run agents; `init` and `check` need
5
- nothing at all. A claude.ai subscription is not a credential here: Anthropic
6
- does not allow products built on its Agent SDK to run on claude.ai logins,
7
- so the key is the way, or any other model provider named with `provider:`
8
- (see [Providers](providers)).
3
+ `foldrun` needs no account, and `init` and `check` need no credential at
4
+ all. To run agents, set one model credential. The first one set is used:
5
+
6
+ | | |
7
+ |---|---|
8
+ | `ANTHROPIC_API_KEY` | an API key from console.anthropic.com |
9
+ | `ANTHROPIC_AUTH_TOKEN` | a bearer token for a gateway, with `ANTHROPIC_BASE_URL` |
10
+ | `CLAUDE_CODE_OAUTH_TOKEN` | a Claude login token: run `claude setup-token` and set what it prints |
11
+
12
+ An agent that names its own `provider:` needs none of these (see
13
+ [Providers](providers)). Every step says which credential it ran on, in the
14
+ run's trail and on the run page, and `foldrun doctor` says which one this
15
+ machine would use. Whatever login Claude Code has on the machine is never
16
+ picked up by itself: with nothing set, `run` stops and says so.
17
+
18
+ Anthropic does not allow products built on its Agent SDK to offer claude.ai
19
+ login unless Anthropic has approved them. A login token is supported here for
20
+ the account that runs foldrun; check that the terms cover your use before you
21
+ rely on it.
22
+
23
+ An agent's own shell can read an API key, as it can when you run Claude Code
24
+ with one in your shell; it cannot read a login token, which Claude Code keeps
25
+ to itself. Script tools and `verify:` commands see neither.
26
+
27
+ An agent gets the tools its files grant and nothing of the machine it runs
28
+ on: a local run does not load the MCP connectors of whoever is signed in to
29
+ claude.ai or Claude Code there, nor any MCP configuration on disk.
9
30
 
10
31
  ## Commands
11
32
 
@@ -28,12 +49,12 @@ so the key is the way, or any other model provider named with `provider:`
28
49
  | `foldrun flow show <flow>` | the flow as the canvas draws it, in a terminal: the trigger, then one block per group — parallel steps listed under their group number — each step with its chips (`approve`, `optional`, `when:`, `case:`/`else`, `each:` ×max, `loop … until`, `verify:`, `retry`, `wait`, `ask:`, `on-fail →`, `model`), and under it its agent's team: `↳ sub-agent`, `· consults`, `may ask you`. What `foldrun check` says about a step or its agent is printed on it, red for an error; exits 1 when there is one. Plain text, no colour, when the output is not a terminal. `--to <workspace>` shows the deployed copy |
29
50
  | `foldrun flow run <flow>` | start a flow on the platform — the same command as `invoke`, spelled the way `agent run` is. `foldrun run` runs HERE; `flow run`/`invoke` run a flow on the platform; `agent run` runs one agent there |
30
51
  | `foldrun flow rotate-hook <flow>` | a new webhook URL for the flow (`POST …/hooks/<flow>/rotate`). The old URL stops working the moment it returns, so it asks first — `--yes` with no terminal. Prints the new URL, which is itself a credential |
31
- | `foldrun flow set <flow> --step <n\|agent> key=value …` | the step editor on the Flows page, from a terminal (core's `updateFlowStep`, and `updateFlowStepInstruction` for `instruction=`): `model`, `effort`, `retry`, `timeout`, `verify`, `when`, `case`, `else`, `loop`, `until`, `each`, `max`, `limits` — only the keys named are touched, `key=` clears one. `each=` takes `lines` only; the other fan-outs are `flow add … fan-out`. Shows the diff, refuses an edit `check` would call a new error, `--dry-run` writes nothing; `--to <workspace>` sends the editor's `PATCH …/flows/<flow> { step, options }` / `{ step, instruction }` with `expect` |
52
+ | `foldrun flow set <flow> --step <n\|agent> key=value …` | the step editor on the Flows page, from a terminal (core's `updateFlowStep`, and `updateFlowStepInstruction` for `instruction=`): `model`, `effort`, `retry`, `timeout`, `verify`, `when`, `case`, `else`, `loop`, `until`, `each`, `max`, `limits` — only the keys named are touched, `key=` clears one. `each=` takes `lines` only; the other fan-outs are `flow add … fan-out`. A value the rewriter would drop or clamp is refused, nothing written: `effort` one of low, medium, high, xhigh, max (or an alias core reads, e.g. `deep`); `timeout` a whole number of seconds (`timeout=900`, not `15m`); `retry` 0–5, `loop` 1–5, `max` 1–20, whole numbers. Shows the diff, refuses an edit `check` would call a new error, `--dry-run` writes nothing; `--to <workspace>` sends the editor's `PATCH …/flows/<flow> { step, options }` / `{ step, instruction }` with `expect` |
32
53
  | `foldrun flow trigger <flow> manual\|webhook\|schedule` | the trigger picker on a flow card (core's `setFlowTrigger`): only the `trigger:`, `schedule:` and `timezone:` lines change. `--schedule "0 9 * * 1"` (5 fields, or `@daily`) and `--timezone <zone>` go with `schedule`. Diff, checked, `--dry-run`; `--to` sends `PATCH { trigger }` |
33
54
  | `foldrun flow move-step <flow> --step <n\|agent>` | dragging a card on the board: `--group <g>` runs it beside group g, `--after <g>` gives it a group of its own after g (`0` = first). Only step numbers change (core's `reorderFlowSteps`). Diff, checked, `--dry-run`; `--to` sends `PATCH { groups }` |
34
55
  | `foldrun flow copy-step <flow> --step <n\|agent>[,<n>…]` | the canvas's Copy: the steps' markdown — line, marker and every option (core's `stepSource`) — on stdout. Reads only |
35
56
  | `foldrun flow paste <flow>` | the canvas's Paste: step markdown from stdin or `--file <path>` goes in as new groups after `--after <g>` (default last), or beside group `--group <g>`, renumbered to fit (core's `pasteSteps`) — `foldrun flow copy-step a --step 2 \| foldrun flow paste b`. Diff, checked, `--dry-run`; `--to` sends `PATCH { edit: { op: "paste" } }` |
36
- | `foldrun flow draft "<what it should do>"` | **Draft with AI**, on the platform (`--to <workspace>`; `--flow <name>` redrafts an existing flow): `POST …/flows/draft` has a model write the flow and any agent files it needs, checks and repairs once, and the CLI prints the notes, every file as a diff and what the check still finds. Nothing is saved until you say yes — `--yes` on a pipe, `--dry-run` never; each file goes through `PUT …/source` with `ifMatch`, so one that changed meanwhile is refused. One model call, charged like a step |
57
+ | `foldrun flow draft "<what it should do>"` | **Draft with AI**, on the platform (`--to <workspace>`; `--flow <name>` redrafts an existing flow): `POST …/flows/draft` has a model write the flow and any agent files it needs, checks and repairs once, and the CLI prints the notes, every file as a diff and what the check still finds. Nothing is saved until you say yes — `--yes` on a pipe, `--dry-run` never; on a pipe without `--yes` it saves nothing and exits 1, though the draft call was made and charged; each file goes through `PUT …/source` with `ifMatch`, so one that changed meanwhile is refused. One model call, charged like a step |
37
58
  | `foldrun tool test <name>` | exercise one tool, alone — no model, no run, no flow. The real thing runs: the HTTP request against the declared `base:`, the script with the workspace's secrets in its environment, the MCP handshake. Prints the transport, whether it exited cleanly, how long it took, everything the tool printed, and any secret it needed by NAME and never by value. Its arguments are `key=value` pairs, one per argument the tool declares, and `--path` is an http tool's probe path. It waits five minutes by default, because a tool that crawls takes as long as it takes — `--timeout <seconds>` waits longer, and a test given up on says so rather than claiming the tool failed |
38
59
  | `foldrun run <target>` | run an agent or a flow HERE, on this machine (`name`, or `flow:name`) — `--test` for a [test run](runs#test-runs). On a platform: `flow run`/`invoke` for a flow, `agent run` for one agent |
39
60
  | `foldrun eval [name]` | one eval, or all of them. `--to <workspace>` runs the DEPLOYED workspace's evals on the platform (`POST …/evals/<name>/run`), waiting for each verdict, and prints the same lines; exits 1 when a case fails |
@@ -78,7 +99,7 @@ so the key is the way, or any other model provider named with `provider:`
78
99
  | `foldrun api version` | Settings → API: the API version this account's requests get when they send no `Foldrun-Version`, whether it is pinned, every version still served and the rate limits. `api version pin <YYYY-MM-DD>` pins it, `unpin` goes back to current (`PATCH /api/account/api`; an admin's) |
79
100
  | `foldrun changelog` | What's new: the platform's release notes, newest first — features, fixes, docs — and which release this install runs (`GET /api/changelog`; `--limit <n>` up to 50, `--json`) |
80
101
  | `foldrun version` | this CLI's version, its `@foldrun/core`'s, and — when a platform is known (`--url`, `--profile`, `FOLDRUN_URL` or where you signed in) — that platform's: version, release time, API version, each component's commit and the browsers its runner image has (with versions where known: `chrome 154.0.8037.58`), from `GET /api/version` (no key needed). Warns when the platform was released with a newer CLI than this one. An unreachable platform is a dim line, not a failure. `--json` prints `{ cli, core, platform }`. `foldrun --version` (or `-v`) prints only the CLI's version, offline. See [Versions and releases](versions) |
81
- | `foldrun doctor` | check the road to the platform, one line each: Node (22 or newer), the CLI and core versions, which of `FOLDRUN_URL`, `FOLDRUN_TOKEN`, `FOLDRUN_TIMEOUT`, `HTTPS_PROXY` are set (never their values), the account it would act as, DNS for the platform's host, and a timed `GET /api/healthz`. The first thing to run when a command cannot reach the platform |
102
+ | `foldrun doctor` | check the road to the platform, one line each: Node (22 or newer), the CLI and core versions, which of `FOLDRUN_URL`, `FOLDRUN_TOKEN`, `FOLDRUN_TIMEOUT`, `HTTPS_PROXY` are set (never their values), which model credential a local run would use (by its variable, never its value), the account it would act as, DNS for the platform's host, and a timed `GET /api/healthz`. The first thing to run when a command cannot reach the platform |
82
103
  | `foldrun storage <verb>` | what a workspace PRODUCED, as opposed to what you wrote — `source` is the other half. `ls [prefix]` lists every file with its size, when it was written, how long ago and which run wrote it (`run:<id>`, `user:<email>` for an upload, `api-key` for a key), newest first, plus what the store holds against its quota. `cat <path>` prints a text file and refuses one that is not text; `cat <path> --preview` asks the platform to read it instead and prints what is in it — a PDF or Word document as paragraphs, a spreadsheet or CSV as a table, a deck as slides, an archive as its listing. `get <path>` downloads it beside you — `--file <path>` names somewhere else, `--force` overwrites. `put <file|folder>...` uploads any files, of any type, into storage under their own names, walking folders whole (`.git`, `node_modules` and `.DS_Store` are skipped); `--into <folder/>` puts them under a folder, `--as <path>` renames a single file, and large files go straight to the bucket by a presigned URL, so size is not capped by the platform's request limit; `rm <path>` removes one. `share <path>` mints a public link to one produced file (7 days; `--ttl <days>`, `--forever`) and prints the URL; `shares` lists the live links (`--all` includes expired and revoked); `unshare <token>` revokes one — the link answers 404 from then on and the token is never reused. `--to <workspace>` from anywhere; inside a workspace folder it is already known |
83
104
  | `foldrun runtimes` | the environments a deployed workspace's agents need — each distinct `runtime:` (an agent's own merged with its tools'), the packages in it, which agents use it, and whether the platform has built it: ready, building, or failed with the installer's own error. `foldrun deploy` prints the same thing after a deploy and waits up to two minutes for builds in progress (`--no-runtimes` skips the wait); this asks again later, and `--wait` holds on while any is building. Exits 1 when one has failed. `--to <workspace>` from anywhere |
84
105
  | `foldrun schedule` | every flow in the account whose `trigger:` is a clock: workspace, flow, the cron line, its timezone, how many steps, whether the scheduler can parse it, and the next three times it fires. The times are the point — `0 5 1-7 * 5` reads as "the first Friday" and fires eight times in twenty-eight days, because day-of-month and day-of-week are OR'd. Exits 1 if any line is unparseable, since an invalid schedule errors nowhere and simply never runs. `--to <workspace>` narrows it |
@@ -150,7 +171,7 @@ so the key is the way, or any other model provider named with `provider:`
150
171
  | `--limit <n>` | `runs`, `billing`, `history`: how many rows, newest first (`runs` default 20, `billing` 15). Each workspace is asked for its own newest `n` before the merge, so one busy desk cannot crowd out a quiet one |
151
172
  | `--step <n>` | `approve`, `reject`: decide one gate, numbered as `report` prints it. Without it, every step of that run which is waiting |
152
173
  | `--note "<text>"` | `approve`: guidance the approved step reads in its prompt — "approve, but skip the Sydney batch". `reject`: the reason, recorded on the trace |
153
- | `--yes` | skip the confirmation, deliberately: `approve`, `stop`, `deploy` (when it would delete files), and a bulk `stop` or `rerun`, `flow rotate-hook`, `flow draft`, `history restore`, `billing plan`, `repo deploy`/`merge`, `workspace set name`, and every delete — `runs rm`, `storage rm`, `storage unshare`, `source rm`, `secrets rm`, `keys revoke`, `workspaces rm`. Required when there is no terminal to ask; without it nothing is deleted |
174
+ | `--yes` | skip the confirmation, deliberately: `approve`, `stop`, `deploy` (when it would delete files), and a bulk `stop` or `rerun`, `flow rotate-hook`, `flow draft`, `history restore`, `restore`, `billing plan`, `repo deploy`/`merge`, `workspace set name`, and every delete — `runs rm`, `storage rm`, `storage unshare`, `source rm`, `secrets rm`, `keys revoke`, `workspaces rm`. Required when there is no terminal to ask; without it nothing is deleted |
154
175
  | `--json` | `report`: the raw run record instead of the report. `observe`, `usage`: the raw document |
155
176
  | `--events <a,b>` | `account set notify`: which events are notified on — `failed`, `awaiting-approval`, `completed` |
156
177
  | `--watch` | `invoke`: follow the run's trace here as it happens; the exit code is the run's |
@@ -175,7 +196,7 @@ so the key is the way, or any other model provider named with `provider:`
175
196
  | `--timeout <s>` | how many seconds one request to the platform may take — or `FOLDRUN_TIMEOUT`. Default 30, and 300 for `tool test`, where the tool itself is the thing being waited on |
176
197
  | `--local` | `deploy`, `secrets`, `logs`: the installation on this machine, even when signed in |
177
198
  | `--quiet` | leave out the dim "acting as …" line a platform command prints on stderr |
178
- | `--dry-run` | `extract`: list what would move, change nothing. `flow add`, `flow rm-step`, `flow dup-step`, `flow set`, `flow trigger`, `flow move-step`, `flow paste`, `flow draft`, `history restore`, `agent link`/`unlink`: show the diff, write nothing. A bulk `stop`/`rerun`: list what matches, touch nothing |
199
+ | `--dry-run` | `extract`: list what would move, change nothing. `flow add`, `flow rm-step`, `flow dup-step`, `flow set`, `flow trigger`, `flow move-step`, `flow paste`, `flow draft`, `history restore`, `agent link`/`unlink`: show the diff, write nothing. `restore`: the diff only, restore nothing. A bulk `stop`/`rerun`: list what matches, touch nothing |
179
200
  | `--out <file>` | `api spec`: where to write the OpenAPI document (default stdout) |
180
201
  | `--file <path>` | `source put`: the file to send. `storage get`, `report get`, `account export`, `billing statement --csv`: where to write — `-` for stdout |
181
202
 
@@ -8,6 +8,7 @@ files are the program — so the loop is edit, check, run, deploy.
8
8
  ```sh
9
9
  npm install -g foldrun
10
10
  export ANTHROPIC_API_KEY=sk-ant-... # an API key from console.anthropic.com
11
+ # or: export CLAUDE_CODE_OAUTH_TOKEN=... from `claude setup-token` (see the CLI reference)
11
12
 
12
13
  foldrun init my-account
13
14
  cd my-account
package/docs/runs.md CHANGED
@@ -132,7 +132,7 @@ What the platform does on a test run, in the order a step meets it:
132
132
  | the egress proxy | any `POST`, `PUT`, `PATCH` or `DELETE` to a host that is not a model provider or a read-only service is **refused** with `403` and a JSON body `{ "test_mode": true, "would_have": { method, host, path, to, body } }`. A Resend `POST /emails` is instead **redirected**: the recipients become `delivered@resend.dev` (Resend's sink), the subject is prefixed `[TEST] `, and the call goes through — so a desk still gets a real message id. Reads (`GET`, `HEAD`, `OPTIONS`) pass. A host with no granted secret is refused as always |
133
133
  | the sandbox | secrets whose name says they can send — `RESEND_API_KEY`, `TWILIO_*`, `GETREACH_API_KEY`, `GITHUB_TOKEN`, `MONDAY_API_TOKEN`, `OI_CRM_API_KEY`, `CLOUDFLARE_*`, `LINKEDIN_*`, `MEDIUM_COOKIES`, `TIKTOK_*` — are **withheld**: the env var reads `TEST_MODE_WITHHELD`, so a script that never checked fails at the provider with an auth error rather than sending. A step granting a script tool marked `outward: true` gets no real secret at all; one granting a tool marked `test_mode: allow` gets them all. The sandbox also has `FOLDRUN_TEST_MODE=1` and `FOLDRUN_RUN_TEST=1`, for scripts that branch on it. The http tools still go through the proxy, where the table above applies |
134
134
  | the browser | the step is handed no account [browser pod](tools#where-the-browser-runs): the tool launches Chromium inside the run pod, which the run namespace's policy denies the internet, so a `web action=browse` call fails closed rather than fetching the world through a pod the policy does not select |
135
- | the write-back | `state/` and `storage/` go into the sandbox as they are; what the step wrote there comes out under `runs/<id>/test-writes/state/…` and `…/storage/…`, and the workspace's copies are untouched. Nothing is harvested to the file store and nothing under `storage/public/` is published |
135
+ | the write-back | `state/` and `storage/` go into the sandbox as they are; what the step wrote there comes out under `runs/<id>/test-writes/state/…` and `…/storage/…`, and the workspace's copies are untouched. Each later step of the run gets those writes laid over its copy, times kept, so a flow that hands work on through `storage/` is tested as it runs live — the step's log says how many files it sees. Nothing is harvested to the file store and nothing under `storage/public/` is published |
136
136
  | the record | `test: true`; the summary opens with `[test]`; a **TEST** badge beside the status in the runs list and on the run page; every refusal, redirect, diversion and withheld secret is an event carrying an `effect`, and the run page's **Test run** panel lists them — "would have written state/sends.md, +3 lines" |
137
137
  | the rest | gates still park, and the approve page says it is a test run; notifications go out only for `failed`; a `trigger: flow` chained on a test run starts as a test run |
138
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldrun",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Agents are just folders. Write them, check them, run them — on your machine.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -11,7 +11,7 @@
11
11
  "node": ">=22"
12
12
  },
13
13
  "dependencies": {
14
- "@foldrun/core": "^0.5.0"
14
+ "@foldrun/core": "^0.6.0"
15
15
  },
16
16
  "files": [
17
17
  "bin",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "e2e": "FOLDRUN_E2E=1 FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-e2e-XXXXXX) node --test --test-timeout=300000 tests/e2e.test.ts",
24
- "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts",
24
+ "test": "FOLDRUN_DATA=$(mktemp -d /tmp/foldrun-test-XXXXXX) node --test tests/cli-account.test.ts tests/model-credential.test.ts tests/cli-approvals.test.ts tests/cli-stop.test.ts tests/cli-account-settings.test.ts tests/cli-schedule.test.ts tests/cli-billing.test.ts tests/cli-storage.test.ts tests/cli-scaffold.test.ts tests/cli-secrets.test.ts tests/cli-site-login.test.ts tests/cli-check-library.test.ts tests/cli-check-timezone.test.ts tests/cli-check-subagents.test.ts tests/cli-connect.test.ts tests/cli-accounts.test.ts tests/cli-remote.test.ts tests/cli-stream.test.ts tests/cli-doctor.test.ts tests/cli-tool-test.test.ts tests/cli-agent-run.test.ts tests/cli-check-remote.test.ts tests/cli-shares.test.ts tests/cli-triggers.test.ts tests/cli-providers.test.ts tests/cli-rerun-put.test.ts tests/cli-guide.test.ts tests/cli-runtimes.test.ts tests/cli-deploy-safety.test.ts tests/cli-operator.test.ts tests/cli-flow-patterns.test.ts tests/cli-flow-rm-step.test.ts tests/cli-flow-dup-step.test.ts tests/cli-flow-write-stale.test.ts tests/cli-agent-link-stale.test.ts tests/cli-parity.test.ts tests/cli-invoke-inputs.test.ts tests/cli-audit-keys.test.ts tests/cli-version.test.ts tests/cli-check-web-warnings.test.ts tests/cli-report-image.test.ts tests/cli-check-workspace-paths.test.ts tests/cli-parity-dashboard.test.ts tests/cli-api-standards.test.ts tests/cli-backups-onboarding.test.ts tests/cli-check-limits.test.ts tests/cli-gallery.test.ts tests/cli-status-platform.test.ts tests/cli-webhooks-notifications.test.ts",
25
25
  "release": "node scripts/release.mjs",
26
26
  "typecheck": "tsc -p tsconfig.json",
27
27
  "docs:sync": "node scripts/sync-docs.mjs",
package/src/commands.mjs CHANGED
@@ -328,7 +328,7 @@ async function scaffoldCmd(kind, positional, flags, layout) {
328
328
 
329
329
  // The other verbs are dispatched before this — `agent run` and `tool test`
330
330
  // go to the platform — so anything left here should have been `new`.
331
- const alsoVerbs = { agents: ["run", "link", "unlink"], tools: ["test"], flows: ["add", "rm-step", "dup-step", "show", "run", "rotate-hook"] }[kind] ?? [];
331
+ const alsoVerbs = { agents: ["run", "link", "unlink"], tools: ["test"], flows: ["add", "rm-step", "dup-step", "show", "run", "rotate-hook", "set", "trigger", "move-step", "copy-step", "paste", "draft"] }[kind] ?? [];
332
332
  const verb = positional[0];
333
333
  if (verb !== "new") {
334
334
  const verbs = ["new", ...alsoVerbs].join(", ");
@@ -1124,16 +1124,22 @@ function bundleDirs(workspace, kind) {
1124
1124
 
1125
1125
  // ---------------------------------------------------------------- run
1126
1126
 
1127
- // An API key, and only that. A claude.ai login on this machine is not a
1128
- // credential foldrun may run on: Anthropic does not allow products built on
1129
- // its Agent SDK to use claude.ai subscriptions, so the CLI neither looks
1130
- // for one nor suggests it. An agent that names its own `provider:` needs
1131
- // no Anthropic key at all — that is checked where the agent is read.
1127
+ // A model credential, named, and never the machine's own login by default:
1128
+ // an API key (ANTHROPIC_API_KEY), a gateway bearer (ANTHROPIC_AUTH_TOKEN),
1129
+ // or a Claude login token (CLAUDE_CODE_OAUTH_TOKEN, from `claude
1130
+ // setup-token`) — in that order, and the run says which (core
1131
+ // model-credential.ts). Before, the key was checked here and then never
1132
+ // reached the step, which ran on whatever login the machine had. An agent
1133
+ // that names its own `provider:` needs none of these — that is checked where
1134
+ // the agent is read. Terms: Anthropic does not allow products built on its
1135
+ // Agent SDK to offer claude.ai login unless it has approved them.
1136
+ const MODEL_CREDENTIALS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"];
1132
1137
  function assertCredentials() {
1133
- if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return;
1138
+ if (MODEL_CREDENTIALS.some((v) => (process.env[v] ?? "").trim())) return;
1134
1139
  throw new Error(
1135
- "no credentials — set ANTHROPIC_API_KEY (an API key from console.anthropic.com),\n" +
1136
- " or give the agent its own `provider:`. `foldrun check` works without either.",
1140
+ "no model credential — set ANTHROPIC_API_KEY (an API key from console.anthropic.com),\n" +
1141
+ " or CLAUDE_CODE_OAUTH_TOKEN (a Claude login token: `claude setup-token`),\n" +
1142
+ " or give the agent its own `provider:`. `foldrun check` works without any.",
1137
1143
  );
1138
1144
  }
1139
1145
 
@@ -3321,6 +3327,20 @@ async function doctor(flags) {
3321
3327
  // Set or unset, never the value: two of these are a key and a proxy URL
3322
3328
  // that may carry one.
3323
3329
  line(true, "env", ["FOLDRUN_URL", "FOLDRUN_TOKEN", "FOLDRUN_TIMEOUT", "HTTPS_PROXY"].map((v) => (env(v) ? `${v} set` : c.dim(`${v} unset`))).join(" · "));
3330
+ // Which model credential a local run would use — said, not judged: a
3331
+ // machine that only drives the platform needs none.
3332
+ try {
3333
+ const { resolveModelCredential } = await core();
3334
+ const m = resolveModelCredential(process.env);
3335
+ console.log(
3336
+ ` ${m.kind === "none" ? c.dim("·") : c.green("✓")} ${"model".padEnd(9)} ` +
3337
+ (m.kind === "none"
3338
+ ? c.dim("no credential for local runs — ANTHROPIC_API_KEY, or CLAUDE_CODE_OAUTH_TOKEN from `claude setup-token`")
3339
+ : `${m.label} — what \`foldrun run\` uses here`),
3340
+ );
3341
+ } catch {
3342
+ // core unresolvable is already its own line above
3343
+ }
3324
3344
 
3325
3345
  let url;
3326
3346
  let token;
@@ -7571,6 +7591,16 @@ async function flowEditCmd(flags, layout, { flow, what, label, compute, bodies }
7571
7591
  return 0;
7572
7592
  }
7573
7593
 
7594
+ /** Two parsed flows that run the same: equal but for key order and the
7595
+ * line numbers a reflow moves. */
7596
+ function sameFlow(a, b) {
7597
+ const canon = (v) =>
7598
+ Array.isArray(v) ? v.map(canon)
7599
+ : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).filter((k) => k !== "line").sort().map((k) => [k, canon(v[k])]))
7600
+ : v;
7601
+ return JSON.stringify(canon(a)) === JSON.stringify(canon(b));
7602
+ }
7603
+
7574
7604
  /** The step options the dashboard's step editor writes (core updateFlowStep). */
7575
7605
  const STEP_OPTION_KEYS = ["model", "effort", "retry", "timeout", "verify", "when", "case", "else", "loop", "until", "each", "max", "limits"];
7576
7606
 
@@ -7586,6 +7616,11 @@ async function flowSetCmd(positional, flags, layout) {
7586
7616
  const pairs = positional.slice(2);
7587
7617
  const usage = `\`foldrun flow set <flow> --step <n|agent> key=value …\` — keys: ${STEP_OPTION_KEYS.join(", ")}, instruction (key= clears)`;
7588
7618
  if (!flow || !pairs.length) throw new Error(usage);
7619
+ const { resolveEffort, EFFORT_LEVELS, parseDuration } = await core();
7620
+ // The counted options, in the ranges core's rewriter and parseFlow share.
7621
+ // Outside them the rewriter clamps, zeroes (retry=abc wrote 0) or drops
7622
+ // the line (timeout=15m deleted timeout: 900) and the edit "succeeded".
7623
+ const COUNTS = { retry: [0, 5, "extra attempts"], loop: [1, 5, "extra cycles"], max: [1, 20, "items"] };
7589
7624
  const options = {};
7590
7625
  let instruction;
7591
7626
  for (const p of pairs) {
@@ -7603,9 +7638,18 @@ async function flowSetCmd(positional, flags, layout) {
7603
7638
  // dropped without a word. Refuse it here and say where it lives.
7604
7639
  if (key === "each" && value && value !== "lines") throw new Error(`each=${value}: flow set writes \`each: lines\` only — \`foldrun flow add <flow> fan-out --each "${value}"\` writes the rest`);
7605
7640
  if (key === "else" && value && !["true", "1"].includes(value)) throw new Error("else=true, or else= to clear it");
7641
+ if (key === "effort" && value && !resolveEffort(value)) throw new Error(`effort=${value} is not an effort — ${EFFORT_LEVELS.join(", ")} (or effort= to clear it)`);
7642
+ if (key === "timeout" && value && !/^[1-9]\d*$/.test(value)) {
7643
+ const secs = parseDuration(value);
7644
+ throw new Error(`timeout=${value}: flow set writes a whole number of seconds — ${secs ? `timeout=${secs}` : "e.g. timeout=900 for 15 minutes"} (or timeout= to clear it)`);
7645
+ }
7646
+ if (key in COUNTS && value) {
7647
+ const [lo, hi, what] = COUNTS[key];
7648
+ if (!/^\d+$/.test(value) || Number(value) < lo || Number(value) > hi) throw new Error(`${key}=${value}: a whole number of ${what}, ${lo} to ${hi} (or ${key}= to clear it)`);
7649
+ }
7606
7650
  options[key] = value === "" ? null : value;
7607
7651
  }
7608
- const { updateFlowStep, updateFlowStepInstruction } = await core();
7652
+ const { updateFlowStep, updateFlowStepInstruction, parseFlow } = await core();
7609
7653
  return flowEditCmd(flags, layout, {
7610
7654
  flow,
7611
7655
  what: "flow set",
@@ -7617,6 +7661,10 @@ async function flowSetCmd(positional, flags, layout) {
7617
7661
  const afterInstruction = instruction !== undefined ? updateFlowStepInstruction(next, i, instruction) : next;
7618
7662
  next = afterInstruction;
7619
7663
  if (Object.keys(options).length) next = updateFlowStep(next, i, options);
7664
+ // The rewriter re-emits the managed lines at the top of the step, so a
7665
+ // value the step already has still came back reordered — a diff, and on
7666
+ // the platform a revision, for no change. Same flow when parsed: none.
7667
+ if (next !== raw && sameFlow(parseFlow("flow.md", raw), parseFlow("flow.md", next))) next = raw;
7620
7668
  return { next, meta: { i, afterInstruction, label: `step ${i + 1} · ${s.subflow ? `flow:${s.subflow}` : s.agent}` } };
7621
7669
  },
7622
7670
  bodies: (raw, m) => [
@@ -7784,8 +7832,9 @@ async function flowDraftCmd(positional, flags, layout) {
7784
7832
  return 0;
7785
7833
  }
7786
7834
  if (flags.yes !== true && !process.stdin.isTTY) {
7787
- console.log(` ${c.dim(`nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`} (the draft will differ), or save them with \`foldrun source put\``)}\n`);
7788
- return 0;
7835
+ // The model call was made and charged; a script must not read that as done.
7836
+ console.log(` ${c.dim(`the draft call was made (charged like a step), but this is not a terminal to ask — nothing saved — run it again with --yes to save ${files.length === 1 ? "this file" : `these ${files.length} files`} (the draft will differ), or save them with \`foldrun source put\``)}\n`);
7837
+ return 1;
7789
7838
  }
7790
7839
  if (!(await confirmed(flags, "flow draft", `Save ${files.length} file${files.length === 1 ? "" : "s"} to ${ws}${files.some((f) => f.before !== null) ? " (replacing what is there)" : ""}? [y/N] `))) {
7791
7840
  console.log(`\n ${c.dim("nothing saved")}\n`);