Public: anyone can read this; the crew can change it.

The docs explain the forge to a person who has never seen it, in the order th… #12237

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/12069-the-docs-explain-the-forge-to-a-person-w → main 25 commits fork 3 threads · 3 unresolved

  • Merged. scout/12069-the-docs-explain-the-forge-to-a-person-w was merged into main as 17507df.

Review

Sign in to review this change.
29 files changed +775 −32
AGENTS.md +2
@@ -2,6 +2,8 @@
22
33 Read `CLAUDE.md` first: it is the rulebook for agents editing this repo.
44
5 The guide (`docs/guide-*.md`, ordered in `src/view/guide.ts`, served at `/docs` with search) is how a person and an outside agent are taught the forge: eleven pages, each at most 450 words, every command, path and link in them checked by `test/guide.test.ts`. A feature's `docs/features/<name>.md` is its reference; link it from the guide page it belongs to. llms.txt points at the same pages.
6
57 README.md is capped: a new feature's how-to goes in `docs/features/<name>.md`, not a new README section.
68 A pull that adds a page (`src/web/pages/*.ts`) also adds a terminal screen under `src/tui/` and a `docs/features/<name>.md`, or a commit line `No door: <why>`; `bun scripts/gate.ts` refuses it otherwise ("both doors", #10628).
79 A pull that changes `src/web/` (a `.ts` or `.css`) names, in a commit line `Looks: <path>`, the page of `app.looks` in forge.json whose before/after pictures show it, or says `No look: <why>`; `bun scripts/gate.ts` refuses it otherwise ("before/after looks", #12362).
docs/README.md +4
@@ -7,6 +7,10 @@ apps, with AI agents that build. Forges connect to one another.
77 The forge is part of [Open Platform](https://www.open-platform.sh), with the
88 platform for organizations.
99
10 ## Start here
11
12 The guide, in order, one or two screens a page: [What a forge is](guide-what-a-forge-is.md), [Your first app in five minutes](guide-first-app.md), [Tell the crew](guide-tell-the-crew.md), [Ship and undo](guide-ship-and-undo.md), [Your apps](guide-your-apps.md), [Hosting forges](guide-hosting.md), [Bring your own AI](guide-own-ai.md), [Agents](guide-agents.md), [Backups and moving](guide-backups-moving.md), [Settings](guide-settings.md), [When something is wrong](guide-when-wrong.md).
13
1014 ## Use it
1115
1216 - [The studio](STUDIO.md): the web interface and the words it uses.
docs/REFERENCE.md +1
@@ -488,6 +488,7 @@ Once a minute the forge measures its landing rate, names the one cause binding i
488488
489489 - [`README.md`](README.md): the documentation index, by what you want to do.
490490 - [`REFERENCE.md`](REFERENCE.md): this page.
491 - The guide, in order: [what a forge is](guide-what-a-forge-is.md), [first app](guide-first-app.md), [tell the crew](guide-tell-the-crew.md), [ship and undo](guide-ship-and-undo.md), [your apps](guide-your-apps.md), [hosting](guide-hosting.md), [your own AI](guide-own-ai.md), [agents](guide-agents.md), [backups and moving](guide-backups-moving.md), [settings](guide-settings.md), [when something is wrong](guide-when-wrong.md).
491492 - [`WRITING.md`](WRITING.md): how to write documentation for the forge.
492493 - [`docs/DEVELOP.md`](DEVELOP.md) — developing the forge: tests, checks, the gate, lint and types.
493494 - [`docs/DEPENDENCIES.md`](DEPENDENCIES.md) — every package in `package.json` and why it is there; the gate refuses a new one without its line.
docs/guide-agents.md added +34
@@ -0,0 +1,34 @@
1 # Agents: MCP, git, or the terminal
2
3 An agent can use a forge three ways. Pick the one that fits.
4
5 ## 1. MCP
6
7 The forge is a Model Context Protocol server at `/mcp`, with OAuth. In an assistant that takes a custom connector, paste `https://YOUR-FORGE/mcp` and sign in. The server card is at `/.well-known/mcp.json`. Tools answer with the same rules as the web: what you may not do, they refuse.
8
9 ## 2. Clone and push
10
11 Any agent that can run `git` can work. With no account at all, an agent starts with one call ([Your first app](guide-first-app.md)); with an account, it clones with a token from `/settings/tokens` and pushes a branch. A push to a branch opens or updates a pull. See [cloning over git](GIT.md).
12
13 ## 3. The terminal
14
15 The `forge` command reads and steers from a shell:
16
17 ```sh
18 forge login
19 forge ls
20 forge asks
21 forge pulls
22 forge tell "words"
23 ```
24
25 `forge ui` opens a full-screen terminal. The forge may also offer an SSH terminal: its exact address is on the forge's own pages; use it as written.
26
27 ## What an agent should read first
28
29 - `/llms.txt`: what this forge is, and each action with its method and URL.
30 - `/agents`: the same as a page.
31 - `/docs`: these guides, with search.
32 - The repository's `AGENTS.md`.
33
34 Reference: [MCP](MCP.md), [CLI](CLI.md), [roles](ROLES.md). Next: [Backups and moving](guide-backups-moving.md).
docs/guide-backups-moving.md added +19
@@ -0,0 +1,19 @@
1 # Backups and moving
2
3 ## Backups
4
5 The forge copies itself every night: repositories, the database and settings. Open `/settings/backups` to see the last copy, send copies somewhere else (a destination you choose), take one now, and run a restore drill that proves a copy works. A copy that has not been proved is not counted as safe, and home says so when a backup is overdue.
6
7 What a copy does not hold is listed in [recovery](RECOVERY.md#what-a-snapshot-does-not-hold).
8
9 ## Restore
10
11 Recovery is a printable guide, with two routes: the restore command, or the objects by hand. Read [recovery](RECOVERY.md) before you need it, and keep the key to your backup off the forge.
12
13 ## Moving
14
15 - **To your machine**: `forge sync` clones every app you follow, and work continues offline. See [on your machine](ON-YOUR-MACHINE.md).
16 - **Between forges**: connect another forge as an origin at `/settings/origins`, bring an app in, and send changes back. See [origins](ORIGINS.md).
17 - **To a new owner**: transfer an app from its Danger zone. Old links keep working ([transfer](features/repo-transfer.md)).
18
19 Next: [Every setting, by topic](guide-settings.md).
docs/guide-first-app.md added +55
@@ -0,0 +1,55 @@
1 # Your first app in five minutes
2
3 You need a forge address (the link you were given) and a terminal with `git` and `curl`. No account is needed to start; you claim the app afterwards.
4
5 ## 1. Start
6
7 Replace `YOUR-FORGE` with the forge's address and run:
8
9 ```sh
10 curl -sX POST https://YOUR-FORGE/api/v1/start -d '{"name":"my-app"}'
11 ```
12
13 The answer is a small JSON document. Four parts matter:
14
15 - `git.url`: where the code lives.
16 - `token`: your password for it. Keep it private.
17 - `app.url`: where the app is live.
18 - `claim.url`: open this in a browser to make the app yours with a real account.
19
20 ## 2. Get the code
21
22 Take `git.url` from the answer and put `USER:TOKEN@` right after `https://`, where `USER` is `user.username` and `TOKEN` is `token`. Then clone it:
23
24 ```sh
25 git clone https://USER:TOKEN@YOUR-FORGE/OWNER/my-app
26 ```
27
28 (`OWNER/my-app` is the rest of `git.url`: copy it as it is.)
29
30 ## 3. Change something and push
31
32 ```sh
33 cd my-app
34 echo "hello" > NOTES.md
35 git add . && git commit -m "first change"
36 git push origin HEAD:main
37 ```
38
39 The forge builds the app on every push to `main`. A few seconds later `app.url` shows it. Open it.
40
41 ## 4. Make it yours
42
43 Open `claim.url` and choose a user name and password. The app and its token move to your account. An app nobody claims sleeps after 7 days and is archived after 30.
44
45 ## 5. Tell the crew
46
47 With the forge's `forge` command installed and signed in, ask for a change in plain words:
48
49 ```sh
50 forge tell "make the page say hello in big letters"
51 ```
52
53 Or open `/talk` and type it. What happens next is in [Tell the crew](guide-tell-the-crew.md).
54
55 If something does not work, see [When something is wrong](guide-when-wrong.md). The reference for the command is [CLI.md](CLI.md).
docs/guide-hosting.md added +21
@@ -0,0 +1,21 @@
1 # Hosting forges for others, and getting paid
2
3 You can make a forge for a person, a client or an organization, and send them one link. They open it and see their crew already at work.
4
5 ## Make one
6
7 Open `/forges/new`, choose the apps to include (some copied from yours, some still shared so they see them change), a plan and an admin. The page gives you one link to send. From an AI assistant the same step is the `spawn` tool.
8
9 ## Plans
10
11 `GET /api/v1/plans` is public and lists the plans: free, small, medium and large, each with a price, a size and a monthly crew credit. The free plan asks the person to bring their own model key ([Bring your own AI](guide-own-ai.md)). A forge admin edits the plans at `/admin/limits`.
12
13 ## Getting paid
14
15 Billing is at `/settings/billing`. Connect your Stripe account there, make payment links and codes, and see orders. If a forge's payment lapses it enters a grace period with a pay link, and then sleeps; its data is kept.
16
17 ## What they get
18
19 A hosted forge sleeps when idle and wakes on its next visit. Each person they add gets their own forge that follows theirs, so good changes flow both ways. The client list at `/forges` shows who was sent what and what happened.
20
21 Reference: [plans](features/plans.md), [new forge first minutes](features/new-forge-first-minutes.md), [the platform](PLATFORM.md). Next: [Bring your own AI](guide-own-ai.md).
docs/guide-own-ai.md added +21
@@ -0,0 +1,21 @@
1 # Bring your own AI
2
3 The crew runs on a model. On a paid plan the forge lends you a monthly crew credit. On the free plan, or whenever you prefer, you bring your own model credential and the crew uses that.
4
5 ## Set it
6
7 Open `/settings/model` and add your credential. The crew's other knobs (who works, how much, how fast) are at `/settings/crew`.
8
9 ## See what it costs
10
11 `/settings/spend` shows what the crew spent today and this month, and `/settings/usage` shows by whom. A crew with no credential and no credit stays off, and says so on home.
12
13 ## Keep it contained
14
15 Agents run in a sandbox with no network except the forge and the hosts an app lists in `egress` in its `forge.json`. Their pulls are reviewed and the tests run before anything lands.
16
17 ## Use your own tools too
18
19 Your own assistant can drive the forge through the MCP: [Agents](guide-agents.md).
20
21 Reference: [the crew](CREW.md), [model grants](features/org-model-grant.md). Next: [Agents](guide-agents.md).
docs/guide-settings.md added +23
@@ -0,0 +1,23 @@
1 # Every setting, by topic
2
3 Settings has two levels: the forge's, at `/settings`, and each app's own tab.
4
5 ## Forge settings
6
7 - **You**: `/settings/everyday` (your everyday account), `/settings/password`, `/settings/passkeys`, `/settings/tokens`, `/settings/devices`, `/settings/notify`.
8 - **Crew and AI**: `/settings/crew`, `/settings/model`, `/settings/spend`, `/settings/usage`, `/settings/packs`.
9 - **People**: `/settings/people`, `/settings/teams`, `/settings/access`, `/settings/accounts`.
10 - **Data**: `/settings/backups`, `/settings/origins`, `/settings/secrets`, `/settings/keys`.
11 - **Money**: `/settings/billing`, `/settings/subscriptions`, `/settings/economics`, `/settings/capacity`.
12 - **Safety**: `/settings/security`, `/settings/trusted`, `/settings/confirms`.
13 - **Switches**: `/settings/features`, `/settings/flags`, `/settings/traffic`, `/settings/telemetry`.
14
15 ## An app's settings
16
17 General (name, default branch, visibility), App (how it runs, variables, domains), Prompts (what the crew is told on this app), Labels, Webhooks, Machines, and the Danger zone (transfer, archive, delete).
18
19 ## Admin
20
21 Admin work uses a separate account from the everyday one, and `/admin/limits` holds the limits and plans. Using AI with admin stays possible, but deliberate.
22
23 Reference: [operating](OPERATING.md), [roles](ROLES.md). Next: [When something is wrong](guide-when-wrong.md).
docs/guide-ship-and-undo.md added +34
@@ -0,0 +1,34 @@
1 # Ship and undo
2
3 Shipping is accepting a pull. The change merges into `main` and the app goes live with it.
4
5 ## Ship
6
7 Open the pull, look at its preview, check that its test result is green, and press Merge. Or from a terminal:
8
9 ```sh
10 forge ship 3
11 ```
12
13 It shows the pull and its check, asks `Ship #3? [y/N]`, and merges. Add `--yes` to skip the question. The forge's rules stay in force: a red check or a conflict refuses the merge and says why.
14
15 ## Look before you ship
16
17 - The preview is a real running copy of the app with the change in it.
18 - `forge pulls` lists the open pulls and their checks.
19 - A pull you do not like can be answered in words: tell the crew what to change, and it updates the same pull.
20
21 ## Undo
22
23 Nothing is lost when you ship: every version is in the git history.
24
25 - Ask the crew: `forge tell "undo what pull 3 changed"`. It opens a new pull that reverses it, and you ship that.
26 - Or do it yourself in your clone: `git revert` the merge commit, then `git push origin HEAD:main`.
27
28 Undo is itself a pull or a push, so it is checked and deployed like any other change.
29
30 ## Hold
31
32 If you are not ready, leave the pull open: it stays previewed and nothing changes in production.
33
34 Reference: [pulls](PULLS.md), [apps](APPS.md), [cloning over git](GIT.md). Next: [Your apps](guide-your-apps.md).
docs/guide-tell-the-crew.md added +36
@@ -0,0 +1,36 @@
1 # Tell the crew
2
3 The crew is the AI agents that build your apps. You steer them by saying what you want, in your own words.
4
5 ## How to ask
6
7 - On the web: `/talk`, or the "tell the crew" box in the toolbar at the bottom of any app or preview.
8 - In a terminal: `forge tell "words"`, with `--repo owner/name` when you are not inside the app's folder.
9 - From an AI assistant: through the MCP (see [Agents](guide-agents.md)).
10
11 Say the outcome, not the code: "the checkout total is wrong when there are two coupons". One thought per ask works best.
12
13 ## What happens next
14
15 1. Your words become an **issue** on the app.
16 2. A crew member picks it up and starts a **run**.
17 3. It opens a **pull** with the change, and the forge runs the app's tests on it.
18 4. The pull gets a **preview**: a running copy you can click through.
19 5. You read it and **ship** it, or tell the crew what to change.
20
21 When the crew needs a decision from you, it appears as an **ask**. `/asks` lists them and `forge asks` shows them in a terminal.
22
23 ## How to read a run
24
25 A run is one piece of the crew's work: it builds, tests or deploys. Open a run from its pull or from the runs list:
26
27 ```sh
28 forge runs
29 forge pulls
30 ```
31
32 A run shows its state (queued, running, ok, failed) and its log. A red run says why in the last lines of the log. Tell the crew "the check on pull 3 failed, fix it" and it reads the log itself.
33
34 ## Related
35
36 Reference: [the crew](CREW.md), [asks](ASKS.md), [pulls](PULLS.md). Next: [Ship and undo](guide-ship-and-undo.md).
docs/guide-what-a-forge-is.md added +27
@@ -0,0 +1,27 @@
1 # What a forge is
2
3 A forge is your own place to build software. It holds your apps, runs them on the web, and has a crew of AI agents that build them when you ask. It is one program and one database file, so it is small, fast and yours.
4
5 You never need to read code to use it. You say what you want, the crew proposes a change, you look at it running, and you say yes or no.
6
7 ## Four words
8
9 - **App**: a thing that runs on the web, with its code in a repository.
10 - **Crew**: the AI agents that work on your apps. They never change your app directly: they open a **pull**, which is a proposed change.
11 - **Preview**: every pull gets its own running copy of the app, so you see the change before you accept it.
12 - **Ship**: accept a pull. The change goes live.
13
14 ## What you can see
15
16 - `/` is home: a timeline of what your crew and your apps are doing.
17 - `/apps` lists your apps and `/explore` lists what others made. Any public app has a "make it yours" button that copies it into your forge.
18 - `/talk` is where you tell the crew something.
19
20 Everything is private until you make it public, and each page says in one line who can see it.
21
22 ## Where to go next
23
24 1. [Your first app in five minutes](guide-first-app.md)
25 2. [Tell the crew](guide-tell-the-crew.md)
26
27 The reference behind this page is [the studio](STUDIO.md) and [the vision](VISION.md).
docs/guide-when-wrong.md added +34
@@ -0,0 +1,34 @@
1 # When something is wrong
2
3 Start here, top to bottom.
4
5 ## The app does not come up after a push
6
7 Open the app's latest deploy run (`forge runs`) and read the last lines of its log. Common causes: `run` in `forge.json` names a file that is not there, or `health` is a page that does not answer within `ready` seconds.
8
9 ## A pull's check is red
10
11 Read its run. Then tell the crew: `forge tell "fix the failing check on pull 3"`.
12
13 ## The crew does nothing
14
15 - No model credential or credit: see `/settings/model`.
16 - Waiting on you: `/asks` lists the decisions it needs.
17 - Over its spend limit: see `/settings/spend` and `/settings/crew`.
18
19 ## I cannot sign in or push
20
21 Your token may have been revoked. Make a new one at `/settings/tokens` and run `forge login` again.
22
23 ## The forge itself is unwell
24
25 - `/healthz` answers if it is up.
26 - `/system` (admins) shows load, disk and the slowest things.
27 - A failed backup or a red restore drill is on `/settings/backups`.
28 - If the forge cannot start, follow [recovery](RECOVERY.md); the ways it can stop are in [failure domains](DOMAINS.md).
29
30 ## Still stuck
31
32 Report a problem on the app's page, or tell the crew about the forge's own repository. Say what you did, what you saw, and what you expected.
33
34 Reference: [operating](OPERATING.md).
docs/guide-your-apps.md added +49
@@ -0,0 +1,49 @@
1 # Your apps
2
3 Each app has a Settings tab (General, App, Prompts, Labels, Webhooks, Machines, Danger zone). The things most people reach for are below.
4
5 ## How an app runs
6
7 The repository says how to run itself in `forge.json` at its root:
8
9 ```json
10 {
11 "app": {
12 "run": "bun run src/server.ts",
13 "health": "/"
14 }
15 }
16 ```
17
18 Only `run` is needed. `health` is the page the forge polls until the app answers.
19
20 ## Domains
21
22 Every app has an address on the forge. To use your own, open the app's Settings, App pane, add the domain, and put the DNS records it shows at your registrar. The forge checks them and says what is missing. Reference: [an app's own domain](features/app-own-domain.md).
23
24 ## Environment and secrets
25
26 Variables are set in the app's Settings, App pane: a name, a value, and where it applies (production or previews). A name ending `_KEY`, `_SECRET` or `_TOKEN` is hidden after you save it. A `.env.example` file in the repository opens as a table of what the app needs ([file-env](features/file-env.md)). Never commit a real secret.
27
28 Your own account's keys, tokens and passkeys are at `/settings/secrets`, `/settings/tokens` and `/settings/passkeys`.
29
30 ## Schedules
31
32 An app can run a path on a timer. Declare it in `forge.json`:
33
34 ```json
35 {
36 "app": { "run": "bun run src/server.ts" },
37 "schedules": [
38 { "name": "print", "cron": "30 6 * * *", "timezone": "America/Chicago", "path": "/jobs/print" }
39 ]
40 }
41 ```
42
43 The forge wakes a sleeping app at that time and calls the path. Printing a daily report is just a schedule that calls a page which produces it.
44
45 ## Backups of an app's data
46
47 An app's own data lives under `FORGE_APP_DATA`. See [an app's encrypted backup](features/app-backup.md).
48
49 Reference: [apps](APPS.md). Next: [Hosting forges](guide-hosting.md).
scripts/gate.ts +1
@@ -56,6 +56,7 @@ export const SLOW: readonly Exclusion[] = [
5656 { file: "test/account-ceiling-reserve.test.ts", reason: "runs two stand-in agent runs together against a daily spend ceiling; 11.4 s a test on the gate box", issue: 1242 },
5757 { file: "test/api-pulls-timing.test.ts", reason: "seeds 130 open pulls with git repositories to time the pulls API; 7.0 s a test on the gate box", issue: 1242 },
5858 { file: "test/apps-adversarial-teardown.test.ts", reason: "races a live preview's deploy against its close and waits out the window; 3.3 s a test on the gate box", issue: 1242 },
59 { file: "test/guide-journey.test.ts", reason: "runs the first-app guide end to end: signup, clone, push and a real deploy; 5.7 s a test on the gate box", issue: 12069 },
5960 { file: "test/apps-crew.test.ts", reason: "runs the crew against a real app, a restart and a push that cannot boot; 3.0 s a test on the gate box", issue: 1242 },
6061 { file: "test/apps-measure.test.ts", reason: "sends 601 reports through the app proxy to reach the rate limit; 3.7 s a test on the gate box", issue: 1242 },
6162 { file: "test/apps-new.test.ts", reason: "waits for a planning call that times out on purpose; 3.2 s a test on the gate box", issue: 1242 },
src/http/server.ts +2 −1
@@ -190,7 +190,7 @@ import { queuePage, queueSwitch } from "../web/pages/queue.ts";
190190 import { chapterAct, chapterPage, chaptersPage } from "../web/pages/chapters.ts";
191191 import { betAct, betPage, betPropose, betsPage } from "../web/pages/bets.ts";
192192 import { changelogJson, changelogPage } from "../web/pages/changelog.ts";
193 import { docsFile, docsFolder } from "../web/pages/docs_link.ts";
193 import { docsFeature, docsFile, docsFolder } from "../web/pages/docs_link.ts";
194194 import { proveAgainSubmit, releaseHoldSubmit, releaseNowSubmit, liftSubmit, releasesPage, rollbackSubmit } from "../web/pages/releases.ts";
195195 import {
196196 changePassword,
@@ -1022,6 +1022,7 @@ export function buildRouter(): Router<Handler> {
10221022 router.add("GET", "/privacy", privacyPage);
10231023 router.add("GET", "/docs", docsFolder);
10241024 router.add("GET", "/docs/:file", docsFile);
1025 router.add("GET", "/docs/features/:file", docsFeature);
10251026 router.add("GET", "/changelog", changelogPage);
10261027 router.add("GET", "/changelog.atom", changelogAtom);
10271028 router.add("GET", "/:owner/:name/api", appApiPage);
src/tui/screens/docs.ts +60 −13
@@ -1,14 +1,15 @@
11 /**
2 * The forge's documentation, the terminal's /docs: the same listing the page
3 * makes (`docEntries`), for anyone, a guest included. Enter reads a document,
4 * rendered as markdown, scrolled with j and k.
2 * The forge's documentation, the terminal's /docs: what the page does. The
3 * guide first, in its order, then the reference; "Search the docs" first of
4 * all, which also reaches the notes under features/. For anyone, a guest
5 * included. Enter reads a document, rendered as markdown, scrolled with j and k.
56 */
67
7 import { docEntries, docText, type DocEntry } from "../../view/docs.ts";
8 import { docSpine, docText, searchDocs, type DocEntry } from "../../view/docs.ts";
89 import type { Ctx, Screen } from "../app/app.ts";
910 import { binding } from "../app/keys.ts";
1011 import { renderMarkdown } from "../ui/markdown.ts";
11 import { Scroll, ListState, drawLines, drawList, type ListRow } from "./common.ts";
12 import { Scroll, ListState, drawLines, drawList, later, promptScreen, type ListRow } from "./common.ts";
1213
1314 export function docRows(entries: readonly DocEntry[]): ListRow[] {
1415 return entries.map((e) => ({ title: e.title }));
@@ -34,22 +35,68 @@ function docScreen(ctx: Ctx, entry: DocEntry): Screen {
3435 };
3536 }
3637
37 export function docsScreen(ctx: Ctx): Screen {
38 /** A row that is a search, not a document. */
39 const SEARCH = "Search the docs…";
40
41 /** The rows of the list: search, the guide in order, then the reference. */
42 export function docList(): { rows: ListRow[]; entries: (DocEntry | undefined)[] } {
43 const { guide, rest } = docSpine();
44 const entries: (DocEntry | undefined)[] = [undefined, ...guide, ...rest];
45 return { rows: [{ title: SEARCH }, ...docRows([...guide, ...rest])], entries };
46 }
47
48 /** The hits of a query as a list of their own; Enter reads one. */
49 function resultsScreen(ctx: Ctx, query: string): Screen {
3850 const state = new ListState();
39 const entries = docEntries();
51 const hits = searchDocs(query);
4052 return {
41 title: "Docs",
42 keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["esc", "q"], "close")],
53 title: `Docs: ${query}`,
54 keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["esc", "q"], "back")],
4355 render(area, s) {
44 drawList(s, area, ctx.theme.c, state, docRows(entries));
56 if (hits.length === 0) drawLines(s, area, ctx.theme.c, new Scroll(), renderMarkdown(`Nothing matches “${query}”. Try one word.`, Math.max(20, area.w - 2), ctx.theme.c));
57 else drawList(s, area, ctx.theme.c, state, docRows(hits));
4558 },
4659 key(e) {
4760 if (e.key === "esc" || e.key === "q") ctx.pop();
4861 else if (e.key === "enter") {
49 const entry = entries[state.index];
50 if (entry !== undefined) ctx.push(docScreen(ctx, entry));
51 } else state.key(e, entries.length);
62 const hit = hits[state.index];
63 if (hit !== undefined) ctx.push(docScreen(ctx, hit));
64 } else state.key(e, hits.length);
5265 return true;
5366 },
5467 };
5568 }
69
70 export function docsScreen(ctx: Ctx): Screen {
71 const state = new ListState();
72 const { rows, entries } = docList();
73 const self: Screen = {
74 title: "Docs",
75 keys: [binding(["j", "k"], "move"), binding(["enter"], "read"), binding(["/"], "search"), binding(["esc", "q"], "close")],
76 render(area, s) {
77 drawList(s, area, ctx.theme.c, state, rows);
78 },
79 key(e) {
80 const search = () =>
81 ctx.push(
82 promptScreen(ctx, self, {
83 title: "Search the docs",
84 label: "Words to find",
85 onSubmit: (q) => {
86 if (q === "") return "Type a word to look for.";
87 return later(ctx, () => resultsScreen(ctx, q.slice(0, 80)));
88 },
89 }),
90 );
91 if (e.key === "esc" || e.key === "q") ctx.pop();
92 else if (e.key === "/") search();
93 else if (e.key === "enter") {
94 const entry = entries[state.index];
95 if (entry === undefined) search();
96 else ctx.push(docScreen(ctx, entry));
97 } else state.key(e, rows.length);
98 return true;
99 },
100 };
101 return self;
102 }
src/view/docs.ts +72
@@ -7,6 +7,7 @@
77 * for the life of the process.
88 */
99
10 import { GUIDE } from "./guide.ts";
1011 import { readdirSync, readFileSync } from "node:fs";
1112 import { join } from "node:path";
1213
@@ -42,6 +43,77 @@ export function docEntry(name: string): DocEntry | undefined {
4243 return DOC_NAME.test(name) ? docEntries().find((e) => e.name === name) : undefined;
4344 }
4445
46 /** The text of an entry from either folder (`features/NAME.md` too). */
4547 export function docText(entry: DocEntry): string {
4648 return readFileSync(join(DOCS, entry.name), "utf8");
4749 }
50
51 const FEATURES = join(DOCS, "features");
52 /** The listing as a person reads it: the guide in its order, then the rest. */
53 export function docSpine(): { guide: readonly DocEntry[]; rest: readonly DocEntry[] } {
54 const list = docEntries();
55 return {
56 guide: GUIDE.flatMap((n) => list.filter((e) => e.name === n)),
57 rest: list.filter((e) => !GUIDE.includes(e.name)),
58 };
59 }
60
61 export const FEATURE_PREFIX = "features/";
62 let features: readonly DocEntry[] | undefined;
63
64 /** The reference notes in `docs/features/`, named `features/NAME.md`. */
65 export function featureEntries(): readonly DocEntry[] {
66 if (features !== undefined) return features;
67 const out: DocEntry[] = [];
68 for (const d of readdirSync(FEATURES, { withFileTypes: true })) {
69 if (!d.isFile() || !DOC_NAME.test(d.name)) continue;
70 out.push({ name: FEATURE_PREFIX + d.name, title: titleOf(d.name, readFileSync(join(FEATURES, d.name), "utf8")) });
71 }
72 out.sort((a, b) => a.name.localeCompare(b.name));
73 return (features = out);
outdated

Duplicates docText exactly. Keep one function.

74 }
75
76 /** An entry in either folder; a feature note's name is `features/NAME.md`. */
77 export function anyDocEntry(name: string): DocEntry | undefined {
78 if (!name.startsWith(FEATURE_PREFIX)) return docEntry(name);
79 const bare = name.slice(FEATURE_PREFIX.length);
80 return DOC_NAME.test(bare) ? featureEntries().find((e) => e.name === name) : undefined;
81 }
82
83 interface Indexed extends DocEntry {
84 readonly lower: string;
85 readonly text: string;
86 }
87 let indexed: readonly Indexed[] | undefined;
88
89 export interface DocHit extends DocEntry {
90 readonly snippet: string;
91 }
92
93 /**
94 * Up to `limit` documents holding every word of `query`, titles first.
95 * Read once, kept for the life of the process.
96 */
97 export function searchDocs(query: string, limit = 30): readonly DocHit[] {
98 const words = query.toLowerCase().split(/\s+/).filter((w) => w !== "").slice(0, 6);
99 if (words.length === 0) return [];
100 indexed ??= [...docEntries(), ...featureEntries()].map((e) => {
101 const text = readFileSync(join(DOCS, e.name), "utf8");
102 return { ...e, text, lower: (e.title + "\n" + text).toLowerCase() };
103 });
104 const hits: { e: Indexed; rank: number }[] = [];
105 for (const e of indexed) {
106 if (!words.every((w) => e.lower.includes(w))) continue;
107 const inTitle = words.every((w) => e.title.toLowerCase().includes(w));
108 hits.push({ e, rank: (isGuideName(e.name) ? 0 : 2) + (inTitle ? 0 : 1) });
109 }
110 hits.sort((a, b) => a.rank - b.rank || a.e.name.localeCompare(b.e.name));
111 return hits.slice(0, limit).map(({ e }) => {
112 const at = e.text.toLowerCase().indexOf(words[0] ?? "");
113 const from = Math.max(0, at - 60);
114 const snippet = e.text.slice(from, from + 160).replace(/\s+/g, " ").trim();
115 return { name: e.name, title: e.title, snippet };
116 });
117 }
118
119 const isGuideName = (name: string): boolean => name.startsWith("guide-");
src/view/guide.ts added +18
@@ -0,0 +1,18 @@
1 /**

The comment says llms.txt and the terminal list read this array. Neither does (llms.txt hardcodes URLs; src/tui/screens/docs.ts uses docEntries). Make the terminal screen and llms.txt read GUIDE, or drop the claim. This is also the missing terminal door (both doors).

2 * The guide: the spine of /docs, eleven short pages in the order a person
3 * needs them. Plain data: the order lives here, the words in `docs/guide-*.md`.
4 * The web index and the terminal list both read this one array.
5 */
6 export const GUIDE: readonly string[] = [
7 "guide-what-a-forge-is.md",
8 "guide-first-app.md",
9 "guide-tell-the-crew.md",
10 "guide-ship-and-undo.md",
11 "guide-your-apps.md",
12 "guide-hosting.md",
13 "guide-own-ai.md",
14 "guide-agents.md",
15 "guide-backups-moving.md",
16 "guide-settings.md",
17 "guide-when-wrong.md",
18 ];
src/web/standards.ts +1
@@ -475,6 +475,7 @@ function renderLlmsTxt(ctx: Ctx, o: string, tell: string | undefined, running: s
475475 `- Run it yourself instead: \`git clone ${UPSTREAM_REPO}.git && cd forge && bun install && bun run src/main.ts\`, then in a second terminal \`bun src/main.ts --first-admin\`, which prints a one-time link that makes you the admin. It needs Bun; its crew needs a runner CLI and a model credential. Or hosted (plans below): ${o}/forges/new, or MCP \`spawn\` with the person's consent. A hosted forge sleeps when idle and wakes on its next page load, push or API call; on the free plan the person brings their own model credential, or its crew stays off.`,
476476 `- Work from your own forge: connect it to this one as an origin, bring an app in, and send your work back. How: ${UPSTREAM_REPO}/raw/main/docs/ORIGINS.md`,
477477 `- Connect an assistant that uses tools: custom connector URL ${mcp.url} (OAuth); start and report need no account.`,
478 `- The guide, with search: ${o}/docs (${o}/docs/guide-first-app.md, ${o}/docs/guide-agents.md)`,
478479 "",
479480 "## Plans",
480481 "",
test/deadends.test.ts −2
@@ -75,8 +75,6 @@ const keyOfProblem = (p: Problem): string =>
7575 * removed the day it is fixed: the test says so when it no longer happens.
7676 */
7777 const KNOWN: ReadonlySet<string> = new Set<string>([
78 // Product decision: `/docs` lists the top folder only; its adapters page points at a folder of the source tree.
79 "dead /docs/:file -> /examples/adapter-template/ (404)",
8078 // Product decision: a signed-in person who opens /signin is shown the form and its doors (sign-up, "start your own forge"), which are no use to them.
outdated

This comment described the entry you removed and now sits above nothing. Delete it.

8179 "wall /signin -> /signup (sign-in wall via /signup for a signed-in viewer)",
8280 "wall /signin -> /signup (403 for a signed-in viewer)",
test/guide-journey.test.ts added +63
@@ -0,0 +1,63 @@
1 /**
2 * A stranger's journey (#12069): someone who has only the forge's address
3 * and "Your first app in five minutes" reaches a live app, using only what
4 * that page says. The test reads the page and runs its commands.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { readFileSync } from "node:fs";
8 import { getRepo } from "../src/core/repos.ts";
9 import { gitClientEnv } from "./helpers/git-client.ts";
10 import { deploySettled, fetchAsHost, startAppsForge, stopAppsForge, type AppsForge } from "./helpers/apps-forge.ts";
11
12 const page = readFileSync(new URL("../docs/guide-first-app.md", import.meta.url), "utf8");
13 const blocks = [...page.matchAll(/```sh\n([\s\S]*?)```/g)].map((m) => m[1] ?? "");
14 const env = gitClientEnv({ GIT_AUTHOR_NAME: "S", GIT_AUTHOR_EMAIL: "[email protected]", GIT_COMMITTER_NAME: "S", GIT_COMMITTER_EMAIL: "[email protected]" });
15
16 let forge: AppsForge;
17 beforeAll(async () => {
18 forge = await startAppsForge();
19 }, 60_000);
20 afterAll(async () => {
21 await forge.runtime.stop();
22 await stopAppsForge(forge);
23 });
24
25 async function sh(script: string, cwd: string): Promise<{ code: number; out: string }> {
26 const p = Bun.spawn(["sh", "-ec", script], { cwd, env, stdout: "pipe", stderr: "pipe" });
27 const out = (await new Response(p.stdout).text()) + (await new Response(p.stderr).text());
28 return { code: await p.exited, out };
29 }
30
31 test("the page's own commands, in its order, reach a live app", async () => {
32 expect(blocks.length).toBe(4); // start, clone, push, then the crew (not run: it needs a signed-in CLI)
33 const at = forge.servers.url;
34 // Step 1: the page's start call, address filled in.
35 const start = blocks[0]!.trim().replace("https://YOUR-FORGE", at);
36 expect(start).toStartWith("curl ");
37 const started = await sh(start, forge.work);
38 expect(started.code).toBe(0);
39 const answer = JSON.parse(started.out) as { user: { username: string }; token: string; git: { url: string }; app: { url: string | null }; claim: { url: string } };
40 // The four parts the page tells the reader to look for.
41 expect(typeof answer.git.url).toBe("string");
42 expect(answer.token).toStartWith("forge_");
43 expect(answer.claim.url).toContain("/claim/");
44 expect("url" in answer.app).toBe(true);
45
46 // Step 2: the clone line, USER and TOKEN put in as the page says.
47 // The page: git.url with USER:TOKEN@ after the scheme, copied as it is.
48 const withKey = answer.git.url.replace("://", `://${answer.user.username}:${answer.token}@`);
49 const clone = blocks[1]!.trim().replace(/https:\/\/USER:TOKEN@YOUR-FORGE\/OWNER\/my-app/, withKey);
50 expect(clone).toContain(withKey);
51 const cloned = await sh(clone, forge.work);
52 expect(cloned.out).not.toContain("fatal");
53 expect(cloned.code).toBe(0);
54
55 // Step 3: the page's change and push (it cd-s into the clone itself).
56 const pushed = await sh(blocks[2]!, forge.work);
57 expect(pushed.code).toBe(0);
58
59 const repo = getRepo(forge.app.db, answer.user.username, "my-app")!;
60 const run = await deploySettled(forge.app, repo.id, (r) => r.env === "production");
61 expect(run.state).toBe("ok");
62 expect((await fetchAsHost(forge.servers, forge.runtime.hostFor("my-app", "production"))).status).toBe(200);
63 }, 120_000);
test/guide.test.ts added +146
@@ -0,0 +1,146 @@
1 /**
2 * The guide (docs/guide-*.md, #12069): eleven short pages whose examples are
3 * checked, not trusted. Every command exists, every path answers, every link
4 * lands, every JSON example parses, no page runs past two phone screens, and
5 * /docs serves the spine first with search.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { existsSync, readFileSync } from "node:fs";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp } from "../src/app.ts";
13 import { commands } from "../src/cli/commands/index.ts";
14 import { serve, type Servers } from "../src/http/server.ts";
15 import { GUIDE } from "../src/view/guide.ts";
16 import { searchDocs } from "../src/view/docs.ts";
17
18 const root = new URL("..", import.meta.url).pathname;
19 const read = (rel: string) => readFileSync(join(root, rel), "utf8");
20 const pages = GUIDE.map((name) => ({ name, text: read(`docs/${name}`) }));
21 /** Two phone screens: about 450 words with its examples. */
22 const MAX_WORDS = 450;
23
24 const fences = (text: string, lang: string): string[] =>
25 [...text.matchAll(new RegExp("```" + lang + "\\n([\\s\\S]*?)```", "g"))].map((m) => m[1] ?? "");
26
27 let dataDir: string;
28 let app: ReturnType<typeof createApp>;
29 let servers: Servers;
30 beforeAll(async () => {
31 dataDir = await mkdtemp(join(tmpdir(), "forge-guide-"));
32 app = createApp({ dataDir, port: 0 });
33 servers = await serve(app);
34 }, 30_000);
35 afterAll(async () => {
36 servers.stop();
37 app.close();
38 await rm(dataDir, { recursive: true, force: true });
39 });
40 const get = (path: string) => fetch(`${servers.url}${path}`, { redirect: "manual" });
41
42 describe("the pages", () => {
43 test("eleven pages, each with a title, each short", () => {
44 expect(GUIDE).toHaveLength(11);
45 for (const p of pages) {
46 expect(p.text).toMatch(/^# \S/);
47 const words = p.text.split(/\s+/).filter((w) => w !== "").length;
48 expect({ page: p.name, over: words > MAX_WORDS }).toEqual({ page: p.name, over: false });
49 }
50 });
51
52 test("every page links onward, and every link lands", () => {
53 for (const p of pages) {
54 for (const m of p.text.matchAll(/\]\(([^)]+)\)/g)) {
55 const target = (m[1] ?? "").split("#")[0] ?? "";
56 if (target.startsWith("http")) continue;
57 expect({ page: p.name, target, found: existsSync(join(root, "docs", target)) }).toEqual({ page: p.name, target, found: true });
58 }
59 }
60 });
61
62 test("every `forge` command and flag in an example exists", () => {
63 const known = new Set(commands.map((c) => c.name));
64 const cliDoc = read("docs/CLI.md");
65 for (const p of pages) {
66 const lines = [...fences(p.text, "sh").flatMap((b) => b.split("\n")), ...[...p.text.matchAll(/`(forge [^`]+)`/g)].map((m) => m[1] ?? "")];
67 for (const line of lines) {
68 const m = /^forge ([a-z][a-z-]*)/.exec(line.trim());
69 if (m === null) continue;
70 expect({ page: p.name, command: m[1], known: known.has(m[1] ?? "") }).toEqual({ page: p.name, command: m[1], known: true });
71 for (const f of line.matchAll(/--[a-z-]+/g)) expect(cliDoc).toContain(f[0]);
72 }
73 }
74 });
75
76 test("every JSON example parses and a forge.json one declares what the forge reads", () => {
77 const apps = read("docs/APPS.md");
78 for (const p of pages) {
79 for (const block of fences(p.text, "json")) {
80 const doc = JSON.parse(block) as { app?: { run?: string }; schedules?: { cron: string; path: string }[] };
81 expect(typeof doc.app?.run).toBe("string");
82 for (const key of Object.keys(doc.app ?? {})) expect(apps).toContain(`"${key}"`);
83 for (const s of doc.schedules ?? []) {
84 for (const key of Object.keys(s)) expect(apps).toContain(`"${key}"`);
85 expect(s.cron.split(" ")).toHaveLength(5);
86 expect(s.path.startsWith("/")).toBe(true);
87 }
88 }
89 }
90 });
91
92 test("the app tabs the guide names are the tabs the app's settings draw", () => {
93 const src = read("src/web/pages/settings.ts");
94 const line = /\(([^)]*Danger zone)\)/.exec(read("docs/guide-your-apps.md"))?.[1] ?? "";
95 for (const tab of line.split(", ")) expect(src).toContain(`label: "${tab}"`);
96 });
97
98 test("every path in code answers on a running forge (not a 404)", async () => {
99 const paths = new Set<string>();
100 for (const p of pages) for (const m of p.text.matchAll(/`(\/[a-z0-9][a-z0-9/._-]*)`/g)) if (!/\/$/.test(m[1] ?? "")) paths.add(m[1] ?? "");
101 expect(paths.size).toBeGreaterThan(30);
102 // An admin's page answers a stranger 404 on purpose:
103 // it must then be a route the forge registers.
104 const routing = ["server.ts", "settings_routes.ts"].map((f) => read(`src/http/${f}`)).join("\n");
105 const registered = (path: string) => routing.includes(`"${path}"`);
106 for (const path of paths) {
107 const missing = (await get(path)).status === 404 && !registered(path);
108 expect({ path, missing }).toEqual({ path, missing: false });
109 }
110 });
111 });
112
113 describe("/docs", () => {
114 test("the index puts the guide first, in order, before the reference", async () => {
115 const text = await (await get("/docs")).text();
116 const at = GUIDE.map((n) => text.indexOf(`href="/docs/${n}"`));
117 expect(at.every((i) => i > 0)).toBe(true);
118 expect([...at].sort((a, b) => a - b)).toEqual(at);
119 expect(text.indexOf("href=\"/docs/VISION.md\"")).toBeGreaterThan(at[at.length - 1] ?? 0);
120 expect(text).toContain('type="search"');
121 });
122
123 test("search finds the guide first, escapes what was typed and shrugs at nothing", async () => {
124 expect(searchDocs("tell the crew")[0]?.name).toBe("guide-tell-the-crew.md");
125 const res = await get("/docs?q=" + encodeURIComponent("tell crew"));
126 expect(await res.text()).toContain('href="/docs/guide-tell-the-crew.md"');
127 const xss = await (await get("/docs?q=" + encodeURIComponent("<script>alert(1)</script>"))).text();
128 expect(xss).not.toContain("<script>alert(1)");
129 expect(await (await get("/docs?q=zzzqqqxx")).text()).toContain("Nothing matches");
130 expect((await get("/docs?q=" + "a".repeat(500))).status).toBe(200);
131 });
132
133 test("a guide page and a feature note render, and a link to a note resolves in /docs", async () => {
134 const page = await get("/docs/guide-your-apps.md");
135 expect(page.status).toBe(200);
136 expect(await page.text()).toContain('href="/docs/features/app-own-domain.md"');
137 expect((await get("/docs/features/plans.md")).status).toBe(200);
138 for (const bad of ["..%2FVISION.md", "nope.md", "%2e%2e%2f%2e%2e%2fpackage.json"]) expect((await get(`/docs/features/${bad}`)).status).toBe(404);
139 });
140
141 test("llms.txt points an agent at the guide", async () => {
142 const text = await (await get("/llms.txt")).text();
143 expect(text).toContain("/docs/guide-first-app.md");
144 expect(text).toContain("/docs/guide-agents.md");
145 });
146 });
test/llms-anonymous.test.ts +3 −2
@@ -46,8 +46,9 @@ const section = (body: string): string[] => {
4646 test("the anonymous text is small, varies by reader, and lists report and start plus one line", async () => {
4747 const res = await fetch(`${servers.url}/llms.txt`);
4848 const body = await res.text();
49 // 6.5 KiB until the three doors (#12015, about 440 bytes) opened the text;
50 // nothing else was cut to make room. 7.5 KiB since the check.txt door's line.
49 // 6.5 KiB until the three doors (#12015, about 440 bytes) opened the text,
50 // then the guide line (#12069, about 100 bytes); nothing else was cut to make room.
51 // 7.5 KiB since the check.txt door's line.
5152 expect(Buffer.byteLength(body)).toBeLessThan(7.5 * 1024);
5253 expect(res.headers.get("vary")).toContain("Cookie");
5354 expect(res.headers.get("vary")).toContain("Authorization");
test/no-polling.test.ts +1
@@ -304,6 +304,7 @@ const NOT_WALKED: Record<string, string> = {
304304 "/bring/:id/:job": "needs a bring job",
305305 "/claim/start/:token": "needs the one-time token an anonymous start returned",
306306 "/docs/:file": "a redirect to the documentation, not a page; the file name is one the seed cannot guess",
307 "/docs/features/:file": "a reference note of the documentation; the seed supplies no :file",
307308 "/fleet/ops/:id": "needs a fleet operation",
308309 "/go/:code": "needs a short code minted by a share",
309310 "/home/archived/:op": "needs an archived-home operation",
test/pages-budget.test.ts +1 −1
@@ -44,7 +44,7 @@ const HEAVIER: Record<string, { statements?: number; git?: number }> = {
4444 };
4545
4646 /** Patterns with no fixture today: lower this as fixtures are added, never raise it silently. */
47 const MAX_UNFIXTURED = 148; // +1: /:owner/:name/pulls/:number/check.txt, plain text with no page to fixture (#12165)
47 const MAX_UNFIXTURED = 149; // +1: /docs/features/:file, a static reference note with no fixture (#12069); +1: /:owner/:name/pulls/:number/check.txt, plain text with no page to fixture (#12165)
4848
4949 /** Streams and live fragments: no page. */
5050 const NOT_PAGES = new Set([
test/security-floor-routes.test.ts +1
@@ -73,6 +73,7 @@ const UNSEEDED: Record<string, string> = {
7373 "/signin/access/:token": "needs a one-time access link an admin minted",
7474 "/talk/:id": TALK,
7575 "/docs/:file": "a file name of the documentation folder; the seed supplies no :file",
76 "/docs/features/:file": "a reference note of the documentation; the seed supplies no :file",
7677 "/bring/:id/:job": "needs a bring job of the signed-in person; the seed brings nothing",
7778 "/fleet/ops/:id": FLEET,
7879 "/:owner/:name/archive/:file": "a tarball download of a ref, not a page; the file name is one the seed cannot guess",

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs