@scribe-platform/cli 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill.d.ts","sourceRoot":"","sources":["../../src/lib/skill.ts"],"names":[],"mappings":"AAKA,sFAAsF;AACtF,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAEhD,6DAA6D;AAC7D,wBAAgB,gBAAgB,IAAI,MAAM,CAEzC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,GAAG,IAAI,CAOhD;AAED,MAAM,MAAM,kBAAkB,GAC1B;IAAE,MAAM,EAAE,WAAW,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACrC;IAAE,MAAM,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GACnC;IAAE,MAAM,EAAE,gBAAgB,CAAA;CAAE,CAAC;AAWjC;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,OAAO,GAAE;IAAE,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAO,GAAG,kBAAkB,CAaxG;AAED,2EAA2E;AAC3E,wBAAgB,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAG5C"}
@@ -0,0 +1,70 @@
1
+ import { cpSync, existsSync, lstatSync, mkdirSync, rmSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ /** The Claude Code skill this package ships, as both its directory and skill name. */
6
+ export const SKILL_NAME = 'scribe-site-builder';
7
+ /** Where Claude Code discovers globally-installed skills. */
8
+ export function skillDestination() {
9
+ return join(homedir(), '.claude', 'skills', SKILL_NAME);
10
+ }
11
+ /**
12
+ * The skill bundled with this install, or null if it can't be found. Two
13
+ * candidates, tried in order:
14
+ *
15
+ * 1. `<package>/skills/<name>` — a published tarball, where `prepack`
16
+ * (scripts/bundle-skill.mjs) copied the repo's skill in.
17
+ * 2. `<repo>/skills/<name>` — a dev checkout, where the skill only exists at
18
+ * the monorepo root.
19
+ *
20
+ * Both are the same number of levels up from this file whether it runs as
21
+ * `src/lib/skill.ts` (tests) or `dist/lib/skill.js` (published).
22
+ *
23
+ * Resolved at call time, on purpose: `scribe update` calls this *after* `npm
24
+ * install -g` has replaced this package's files, so it reads the skill that
25
+ * ships with the version just installed rather than the one the running process
26
+ * started with.
27
+ */
28
+ export function bundledSkillPath() {
29
+ const here = dirname(fileURLToPath(import.meta.url));
30
+ const candidates = [
31
+ join(here, '..', '..', 'skills', SKILL_NAME),
32
+ join(here, '..', '..', '..', '..', 'skills', SKILL_NAME),
33
+ ];
34
+ return candidates.find((candidate) => existsSync(join(candidate, 'SKILL.md'))) ?? null;
35
+ }
36
+ /** lstat without throwing, so a missing path and a broken symlink are both answerable. */
37
+ function lstatOrNull(path) {
38
+ try {
39
+ return lstatSync(path);
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ /**
46
+ * Copies the bundled skill into ~/.claude/skills, replacing whatever is there.
47
+ *
48
+ * A **symlink** at the destination is left untouched: that's a dev checkout
49
+ * pointing at the repo's own skills/ directory, and overwriting it with a copy
50
+ * would silently detach the author's live editing target.
51
+ */
52
+ export function installSkill(options = {}) {
53
+ const source = options.source === undefined ? bundledSkillPath() : options.source;
54
+ const dest = options.dest ?? skillDestination();
55
+ if (!source)
56
+ return { status: 'missing-source' };
57
+ const existing = lstatOrNull(dest);
58
+ if (existing?.isSymbolicLink())
59
+ return { status: 'symlink', dest };
60
+ mkdirSync(dirname(dest), { recursive: true });
61
+ rmSync(dest, { recursive: true, force: true });
62
+ cpSync(source, dest, { recursive: true });
63
+ return { status: 'installed', dest };
64
+ }
65
+ /** Shortens a path for display, so output reads `~/.claude/skills/...`. */
66
+ export function tildify(path) {
67
+ const home = homedir();
68
+ return path.startsWith(home) ? `~${path.slice(home.length)}` : path;
69
+ }
70
+ //# sourceMappingURL=skill.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"skill.js","sourceRoot":"","sources":["../../src/lib/skill.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,UAAU,EAAE,SAAS,EAAE,SAAS,EAAE,MAAM,EAAc,MAAM,SAAS,CAAC;AACvF,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAClC,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAC1C,OAAO,EAAE,aAAa,EAAE,MAAM,UAAU,CAAC;AAEzC,sFAAsF;AACtF,MAAM,CAAC,MAAM,UAAU,GAAG,qBAAqB,CAAC;AAEhD,6DAA6D;AAC7D,MAAM,UAAU,gBAAgB;IAC9B,OAAO,IAAI,CAAC,OAAO,EAAE,EAAE,SAAS,EAAE,QAAQ,EAAE,UAAU,CAAC,CAAC;AAC1D,CAAC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,UAAU,gBAAgB;IAC9B,MAAM,IAAI,GAAG,OAAO,CAAC,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC;IACrD,MAAM,UAAU,GAAG;QACjB,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC;QAC5C,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,UAAU,CAAC;KACzD,CAAC;IACF,OAAO,UAAU,CAAC,IAAI,CAAC,CAAC,SAAS,EAAE,EAAE,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,EAAE,UAAU,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC;AACzF,CAAC;AAOD,0FAA0F;AAC1F,SAAS,WAAW,CAAC,IAAY;IAC/B,IAAI,CAAC;QACH,OAAO,SAAS,CAAC,IAAI,CAAC,CAAC;IACzB,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,YAAY,CAAC,UAAqD,EAAE;IAClF,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC,gBAAgB,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC;IAClF,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,gBAAgB,EAAE,CAAC;IAEhD,IAAI,CAAC,MAAM;QAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAEjD,MAAM,QAAQ,GAAG,WAAW,CAAC,IAAI,CAAC,CAAC;IACnC,IAAI,QAAQ,EAAE,cAAc,EAAE;QAAE,OAAO,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC;IAEnE,SAAS,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC9C,MAAM,CAAC,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC;IAC/C,MAAM,CAAC,MAAM,EAAE,IAAI,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IAC1C,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;AACvC,CAAC;AAED,2EAA2E;AAC3E,MAAM,UAAU,OAAO,CAAC,IAAY;IAClC,MAAM,IAAI,GAAG,OAAO,EAAE,CAAC;IACvB,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,IAAI,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;AACtE,CAAC"}
@@ -0,0 +1,5 @@
1
+ import type { VerifyReport } from './client.js';
2
+ /** Whether anything actually failed. The caller's exit code hangs off this, and a skip never contributes. */
3
+ export declare function verifyFailed(report: VerifyReport): boolean;
4
+ export declare function printVerifyReport(report: VerifyReport, write?: (line: string) => void): void;
5
+ //# sourceMappingURL=verify-report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-report.d.ts","sourceRoot":"","sources":["../../src/lib/verify-report.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAUhD,6GAA6G;AAC7G,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,OAAO,CAE1D;AAED,wBAAgB,iBAAiB,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,GAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAkB,GAAG,IAAI,CAwCzG"}
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Printing an RFC 0024 verification. Shared by `scribe site verify` and
3
+ * `scribe site deploy`, so a deploy's verdict reads identically to a standalone
4
+ * one and there is no second opinion about what counts as a failure.
5
+ */
6
+ const MARK = { pass: '✓', fail: '✗', skip: '–' };
7
+ /** Whether anything actually failed. The caller's exit code hangs off this, and a skip never contributes. */
8
+ export function verifyFailed(report) {
9
+ return report.failed.length > 0;
10
+ }
11
+ export function printVerifyReport(report, write = console.log) {
12
+ write(`\nVerifying "${report.slug}" — ${report.url}`);
13
+ if (report.pagesChecked.length === 0) {
14
+ // Not a failure. A Site provisioned minutes ago has nothing deployed to look
15
+ // at, and saying so is more useful than five identical skip lines.
16
+ write('\n Nothing deployed yet — no page could be fetched, so nothing was checked.');
17
+ for (const error of report.pageErrors)
18
+ write(` ${error.url} — ${error.reason}`);
19
+ }
20
+ else {
21
+ const suffix = report.truncated ? ` (capped — more pages exist than were checked)` : '';
22
+ write(` ${report.pagesChecked.length} page(s) checked${suffix}\n`);
23
+ }
24
+ for (const check of report.checks) {
25
+ write(` ${MARK[check.state] ?? '?'} ${check.name.padEnd(10)} ${check.message}`);
26
+ for (const line of check.detail ?? [])
27
+ write(` ${line}`);
28
+ }
29
+ if (report.pagesChecked.length > 0 && report.pageErrors.length > 0) {
30
+ write('\n Pages that could not be fetched (not counted either way):');
31
+ for (const error of report.pageErrors)
32
+ write(` ${error.url} — ${error.reason}`);
33
+ }
34
+ for (const error of report.repoErrors)
35
+ write(`\n ! ${error}`);
36
+ write('');
37
+ if (verifyFailed(report)) {
38
+ write(` ${report.failed.length} check(s) failed: ${report.failed.join(', ')}`);
39
+ }
40
+ else if (report.pagesChecked.length === 0) {
41
+ // Not "all checks passed". `dispatch` reads repo files and runs for every
42
+ // Site, deployed or not, so a count of what passed would read as a clean
43
+ // bill of health for a Site nothing is serving. This Site is deliberately
44
+ // not recorded as verified either.
45
+ write(' Nothing about the deployed Site was checked, so it is not recorded as verified.');
46
+ write(' Deploy it, then run this again.');
47
+ }
48
+ else {
49
+ const skipped = report.skipped.length > 0 ? `, ${report.skipped.length} not applicable` : '';
50
+ write(` All ${report.ran} applicable check(s) passed${skipped}.`);
51
+ }
52
+ write('');
53
+ }
54
+ //# sourceMappingURL=verify-report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"verify-report.js","sourceRoot":"","sources":["../../src/lib/verify-report.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scribe-platform/cli",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Command-line interface for Scribe CMS — scaffold sites, edit content, and triage form submissions from the terminal",
5
5
  "license": "MIT",
6
6
  "author": "Dave Weaver",
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "bin",
34
34
  "dist",
35
+ "skills",
35
36
  "README.md",
36
37
  "LICENSE"
37
38
  ],
@@ -43,6 +44,7 @@
43
44
  },
44
45
  "scripts": {
45
46
  "build": "tsc -p tsconfig.json",
47
+ "prepack": "node scripts/bundle-skill.mjs",
46
48
  "test": "NODE_OPTIONS=--experimental-vm-modules jest",
47
49
  "lint": "tsc -p tsconfig.json --noEmit",
48
50
  "prepublishOnly": "npm run build"
@@ -0,0 +1,390 @@
1
+ ---
2
+ name: scribe-site-builder
3
+ description: Guides building a website on Scribe CMS end to end — designing the Astro site freely against Scribe as its backend, editing the `.scribe.yml` schema, adding one or many forms, the local dev loop, pushing content, and deploying — using the `scribe` CLI and the recipes in `recipes/`. Also the reference for working an existing Scribe site (adding fields/collections, publishing content, triaging form submissions, deleting a site). Use whenever the user wants to build/create/start a new website or site with Scribe, add a form to a site, work on an existing Scribe site, or asks about the `scribe` CLI.
4
+ ---
5
+
6
+ # Building a site with Scribe
7
+
8
+ Source of truth: this file is the merged, agent-facing version of
9
+ `docs/BUILDER-GUIDE.md` and `docs/AGENT-GUIDE.md` in the
10
+ `ai-xerro-agent/projects/scribe` repo. If those docs and this one ever
11
+ disagree, the repo docs win — this file may be behind.
12
+
13
+ ## Recipes — read the one you need
14
+
15
+ Per RFC 0010 the Astro site is **yours to design**. Scribe doesn't supply a
16
+ template you must conform to; it supplies patterns. Load the relevant file
17
+ instead of reading all of them:
18
+
19
+ | Recipe | Use it when |
20
+ |---|---|
21
+ | [`recipes/schema.md`](./recipes/schema.md) | you need the Site's collections and field types |
22
+ | [`recipes/collections.md`](./recipes/collections.md) | rendering editor-managed content |
23
+ | [`recipes/forms.md`](./recipes/forms.md) | adding one or more forms |
24
+ | [`recipes/deploy.md`](./recipes/deploy.md) | build/deploy setup or debugging |
25
+
26
+ [`recipes/README.md`](./recipes/README.md) lists the five things that are a hard
27
+ contract (because Scribe writes into the repo) as opposed to advice.
28
+
29
+ ## Mental model
30
+
31
+ - **A site = a GitHub repo.** Schema, content, and the Astro source all live in one
32
+ repo. The admin app and the `scribe` CLI are both just clients that read/write
33
+ files in it via the GitHub API; a local `git clone` is a third client reading/writing
34
+ the same files via git.
35
+ - **Form submissions are the exception — they live in MongoDB, not the repo** (RFC
36
+ 0004). A Visitor's name, email and message used to be committed as a YAML file into
37
+ the site's public repo, which published them to the open internet. Nothing writes
38
+ submissions to a repo any more: a submission is a Mongo document with a status and
39
+ an append-only event history, and `content/<form-collection>/` doesn't exist.
40
+ - **`.scribe.yml`** at the repo root is the schema — every collection (content type)
41
+ and its fields, plus which collections are forms. Prefer the CLI over hand-editing,
42
+ because it validates before committing and because the admin treats the committed
43
+ file as authoritative: `scribe schema show <site>` to read,
44
+ `scribe schema apply <site> --file <spec>` to create or replace a whole collection,
45
+ `scribe schema add`/`remove` for a bare stub or a removal. Hand-editing still works
46
+ for a one-line tweak in a clone.
47
+ - **A Site can have many forms.** Each `form: true` collection gets its own inbox,
48
+ its own workflow states and its own nav badge in the admin, and posts to
49
+ `/api/submissions?site=…&collection=…`. See `recipes/forms.md`.
50
+ - **No merge tooling.** Admin-UI edits and local edits both land as commits on the
51
+ same repo. If they touch the same file, resolve it with plain `git pull` / merge —
52
+ there's no `scribe` conflict command.
53
+ - **`scripts/generate-config.mjs` is legacy.** Sites scaffolded before RFC 0010 have
54
+ it, and it regenerates `src/content.config.ts` and `src/site-config.ts` on
55
+ `predev`/`prebuild` — so in those repos, don't hand-edit those two files, it will
56
+ overwrite you. It flattens the schema lossily (field *names* with no types, and
57
+ only the **first** `form: true` collection), so a site that needs real types or a
58
+ second form should read `.scribe.yml` directly instead — see `recipes/schema.md`.
59
+ New sites shouldn't add it.
60
+
61
+ ## Setup (once)
62
+
63
+ - **CLI**: confirm `scribe --help` works. If it's not found, `npm link` from
64
+ `packages/cli` in the `ai-xerro-agent/projects/scribe` repo.
65
+ - **Login**: `scribe login --token <key>` — mint the key from the
66
+ admin UI (sign in → account menu → **API Tokens** → New token). Defaults to the
67
+ hosted admin at `https://scribe.weaverdigital.io`; add `--api-url <url>` only to
68
+ point at a different instance (a local dev/Docker admin). Writes
69
+ `~/.scribe/config.json`; every other command reads from there, no per-invocation
70
+ `--token` flag. If a command fails with a raw `fetch failed`, that's not an API
71
+ error — it means `apiUrl` in that config doesn't point at anything listening;
72
+ re-run `scribe login` with the right URL.
73
+
74
+ ## Keeping Scribe up to date
75
+
76
+ `scribe update` updates the CLI **and** reinstalls this skill, which ships inside
77
+ the `@scribe-platform/cli` npm package:
78
+
79
+ ```
80
+ scribe update
81
+ ```
82
+
83
+ Two things to know:
84
+
85
+ - **Tell the user to restart Claude Code afterwards.** Claude Code only discovers
86
+ skills at session start, so the session that ran the update is still reading the
87
+ old copy of this file. If you need the new instructions in the current session,
88
+ read `~/.claude/skills/scribe-site-builder/SKILL.md` from disk instead of waiting
89
+ for a restart.
90
+ - **It only works for a global npm install** (`npm install -g
91
+ @scribe-platform/cli`). In a dev checkout of the scribe repo it refuses, and if
92
+ `~/.claude/skills/scribe-site-builder` is a symlink into a checkout it leaves it
93
+ alone — that's an author editing the skill in place.
94
+
95
+ Run it when the user says there's a new version, or when a CLI command fails in a
96
+ way that suggests this skill is behind the API (an option the CLI doesn't
97
+ recognise, a command that no longer exists).
98
+
99
+ ## Workflow: building a new site
100
+
101
+ 1. **Scaffold.**
102
+ ```
103
+ scribe site add --slug my-site
104
+ ```
105
+ Creates the GitHub repo, pushes the Astro starter template
106
+ (home/blog/contact pages, a default `.scribe.yml` with `posts` and `inquiries`
107
+ collections), and registers the site with the admin — one call.
108
+ `--name` sets a display name (defaults to the slug); `--form-collection` renames
109
+ the default contact-form collection (defaults to `inquiries`).
110
+
111
+ `--domain` and `--turnstile-key` are both optional and normally omitted. The
112
+ domain defaults to the `scribe-platform.github.io` Pages host every new site is
113
+ served from, and the Turnstile site key defaults to the shared widget configured
114
+ on the admin app — one widget covers all sites, scoped by its allowed-domain
115
+ list rather than by key. Pass them only to override those defaults. The real
116
+ customer domain comes later, at step 8.
117
+
118
+ 2. **Clone it locally.**
119
+ ```
120
+ scribe site clone my-site
121
+ ```
122
+ Plain `git clone`. Nothing Scribe-specific happens locally — `npm run dev` works,
123
+ `.scribe.yml` is just a file in the repo root.
124
+
125
+ 3. **Define/edit the schema.** Hand-edit `.scribe.yml` — add fields under
126
+ `collections[].fields[]` (see field reference below). Once saved,
127
+ `npm run dev` / `npm run build` locally regenerate the Astro content types
128
+ automatically (via `predev`/`prebuild`) — no separate sync step. A `git push` of
129
+ `.scribe.yml` is enough to reach the admin, which reads it live from GitHub,
130
+ cached **8 minutes** per repo — after a schema push, the admin UI may lag up to
131
+ 8 minutes before showing it.
132
+
133
+ 4. **Build content locally.** Normal Astro dev loop — content files live under
134
+ `content/<collection>/*` per the collection's `path`/`format`.
135
+
136
+ 5. **Push to Scribe.** Two ways:
137
+ - `git push` — commits schema and/or content straight to the repo. Normal path.
138
+ - `scribe content publish <site> <file>` — pushes one local file through the
139
+ API, validating it against the schema server-side first (a raw `git push`
140
+ skips that validation).
141
+
142
+ 6. **Admin-UI edits, pulled back down.** The admin UI writes commits to the same
143
+ repo — `git pull` locally to pick them up. Conflicts resolve as ordinary git
144
+ merge conflicts, nothing purpose-built.
145
+
146
+ 7. **Deploy.** Any push to the repo's default branch runs `.github/workflows/deploy.yml`
147
+ automatically (build + GitHub Pages). Two ways to do it on demand:
148
+
149
+ ```
150
+ scribe site deploy my-site # dispatch, wait for the build, then verify
151
+ scribe build trigger my-site # dispatch and return immediately
152
+ ```
153
+
154
+ **Prefer `site deploy`.** It waits for the Pages run and then checks the
155
+ deployed pages against Scribe's five site invariants (RFC 0024) — the tracker,
156
+ the spam protection on any form, the submission retry queue, the deploy
157
+ trigger the admin publishes through, and the base path. Every one of those
158
+ fails silently: nothing errors, the build is green, and the page looks right.
159
+ `--skip-verify` exists and prints what it gave up.
160
+
161
+ `build trigger` stays as the fire-and-forget primitive. It is debounced to once
162
+ per 5 minutes per site, so repeat calls in that window are a silent no-op —
163
+ and it tells you nothing about whether the result is correct.
164
+
165
+ `scribe site verify my-site` runs the checks on their own, against whatever is
166
+ currently live. Exits non-zero on a failure, so it works in a script.
167
+
168
+ 8. **Domain cutover — only when a customer's own hostname is ready.** Skip this
169
+ entirely for demo sites; the GitHub Pages default already works.
170
+
171
+ Check what DNS the domain needs first. This changes nothing:
172
+ ```
173
+ scribe site promote my-site --domain example.com --dns-only
174
+ ```
175
+ It prints the records to add at the registrar. An apex domain needs the four A
176
+ records; a subdomain needs only the CNAME. **In Cloudflare the record must be
177
+ DNS-only (grey cloud), not proxied** — the orange-cloud proxy blocks the
178
+ challenge GitHub uses to issue the site's certificate.
179
+
180
+ Once the records are in place and resolving:
181
+ ```
182
+ scribe site promote my-site --domain example.com
183
+ ```
184
+ This does the whole cutover in one go: writes `public/CNAME`, registers the
185
+ domain with GitHub Pages, points the contact form at the admin app, updates the
186
+ hostname the public form endpoint accepts submissions from, adds the domain to
187
+ the Turnstile widget, enforces HTTPS, and redeploys.
188
+
189
+ It **refuses to run until DNS actually points at GitHub Pages**, because the
190
+ cutover kills the old project-page URL — if DNS isn't ready the site is
191
+ unreachable at both addresses. Fix the DNS and run it again rather than reaching
192
+ for `--force`.
193
+
194
+ **Re-running is safe and is the fix for anything that goes wrong.** Every step
195
+ checks the current state before writing, so a run that fails halfway is repaired
196
+ by running the same command again. If it reports that GitHub is still issuing
197
+ the certificate, wait a few minutes and re-run to finish — until then `http://`
198
+ won't serve the site.
199
+
200
+ If it prints a `Warning:` about the Turnstile widget, the domain list did **not**
201
+ sync — fix it in the Cloudflare dashboard, or the contact form silently rejects
202
+ real visitors on that domain.
203
+
204
+ `scribe site update my-site --domain example.com` only edits the stored record
205
+ and the Turnstile widget. Use it to correct a domain, never to cut a site over.
206
+
207
+ ## Working an existing site
208
+
209
+ ```
210
+ scribe content list <site> [--collection <name>] [--json]
211
+ scribe content read <site> <path> [--json]
212
+ scribe content publish <site> <file> # validates against schema
213
+ scribe content delete <site> <path>
214
+ scribe content versions <site> <path> [--json] # commit history
215
+ scribe content revert <site> <path> --sha <sha> # checks out old version as new commit
216
+
217
+ scribe schema show <site> [collection] [--json] # read the schema
218
+ scribe schema apply <site> --file <spec|-> # create/replace a whole collection
219
+ scribe schema add <site> <collection-name> [--path <path>] # bare stub only, no fields
220
+ scribe schema remove <site> <collection-name> # deletes its folder too
221
+
222
+ scribe submission list <site> <collection> [--status <status>] [--page <n>] [--json]
223
+ scribe submission update <site> <collection> <id> [--status <s>] [--note <n>]
224
+ scribe submission export <site> <collection> [--format csv|json] [--status <s>] [--out <file>]
225
+ scribe submission delete <site> <collection> <id> [--yes] # staff only
226
+ ```
227
+
228
+ - `scribe schema apply` is the way to add a form (or any collection with fields): it
229
+ takes a YAML/JSON file describing **one whole collection**, upserts it by `name`,
230
+ and validates through the admin's own parser first — so a reserved `status` field,
231
+ an `image` field on a form collection, or a malformed workflow is reported before
232
+ anything is committed. `--file -` reads from a pipe. Full worked example in
233
+ [`recipes/forms.md`](./recipes/forms.md).
234
+ - `scribe schema add` only appends a bare `{name, path}` stub with no fields; it's for
235
+ starting a content collection interactively. There is still no *field*-level
236
+ command — `schema apply` replaces the whole collection, so include every field.
237
+ - `scribe content publish` reads a **local file at the given repo-relative path**,
238
+ parsing `---`-delimited YAML/JSON frontmatter (or a bare YAML/JSON object with no
239
+ body).
240
+ - `scribe submission list` paginates (1-based `--page`); `--json` gives the whole page
241
+ object, so `total` tells you whether there's a page 2.
242
+ - **`<id>` is a Mongo ObjectId**, as printed by `submission list` — not a filename and
243
+ not a repo path. There's no path inference any more; submissions aren't files.
244
+ - `--note` **appends** to the submission's history. It never overwrites an earlier
245
+ note, and it leaves the submission's own `updatedAt` alone — a note is something
246
+ someone wrote *about* the lead, not a change to it.
247
+ - `submission export` writes to stdout unless `--out` is given, so it pipes
248
+ (`scribe submission export my-site inquiries > leads.csv`). CSV is for a
249
+ spreadsheet and is lossy on purpose: its columns come from the collection's schema,
250
+ so the header row is identical between exports even when a batch left a field blank,
251
+ and it carries no history and no visitor IP or user agent. `--format json` is
252
+ lossless, with each submission's events nested under it.
253
+ - `submission delete` is permanent, cascades the submission's history, and needs a
254
+ **staff** role — a site user's API key gets a 403. It prompts for the id unless
255
+ `--yes` is passed. There is deliberately no equivalent in the admin UI, so don't go
256
+ looking for a delete button on the inbox.
257
+
258
+ ## Images — never hotlink, always commit the file
259
+
260
+ **A `type: image` field is just a string.** Nothing in Scribe validates that its
261
+ value is an uploaded image — the schema doesn't know or care whether you write
262
+ `/uploads/some-photo.jpg` or `https://images.unsplash.com/photo-xyz`. When you (the
263
+ agent) are hand-authoring content files directly in a cloned repo — which is the
264
+ normal CLI workflow — **do not put an external image URL (Unsplash or otherwise)
265
+ into an image field's value.** It looks fine at first glance and then quietly means
266
+ the site depends forever on a third-party host it doesn't control, the image was
267
+ never actually reviewed/chosen for that specific listing, and it breaks the "this
268
+ site's content lives in this repo" model the rest of Scribe assumes.
269
+
270
+ Instead, every image an agent puts into content must be a **real file committed to
271
+ the site's own repo**, the same way the admin's own upload feature does it:
272
+
273
+ 1. Get the actual image bytes — download it (a real stock photo, something the
274
+ user supplied, whatever the source) to a local temp file.
275
+ 2. Save it into the cloned repo at `public/uploads/<name>`, with a name unique
276
+ enough not to collide (mirror the admin's own convention:
277
+ `<timestamp>-<safe-filename>`, letters/digits/`._-` only).
278
+ 3. Reference it in the content file's frontmatter as `/uploads/<name>` — a
279
+ repo-relative path, not the original external URL.
280
+ 4. Commit and push (`public/uploads/...` + the content file) together, same as any
281
+ other content change. There's no `scribe` CLI command for this — it's a plain
282
+ file in the repo, same as everything else.
283
+
284
+ This applies to `list: true` image fields too — one committed file per array entry,
285
+ same convention. A single-image field (`type: image`, no `list`) and a list one both
286
+ end up as `/uploads/...` paths; only the shape (string vs. array of strings)
287
+ differs.
288
+
289
+ The one case where a bare external URL is fine: the user explicitly says to leave a
290
+ placeholder / link out to somewhere else on purpose. Don't default to that — treat
291
+ "add a photo of X" as "go get a real image and commit it," not "find any URL that
292
+ renders."
293
+
294
+ ## Deleting a site
295
+
296
+ ```
297
+ scribe site delete <slug> [--yes]
298
+ ```
299
+
300
+ Permanent — deletes the GitHub repo (schema, content, Astro source) and every Mongo
301
+ record for the site, which since RFC 0004 includes its submissions and their event
302
+ history. No undo. Prompts to retype the
303
+ slug unless `--yes` is passed. Requires an **unrestricted** API key (no
304
+ `siteSlugs` restriction) — same rule as `site add`; a key scoped to only that
305
+ site is rejected. Same action is in the admin UI: account menu → **Settings**
306
+ (super-admin only) → **Danger zone**.
307
+
308
+ Deleting also drops the site's domain from the Turnstile widget — but only if no
309
+ other site still uses that domain, so removing one demo never breaks the contact
310
+ form on the rest. A `Warning:` in the output means that cleanup didn't happen and
311
+ the stale domain is still on the widget; harmless, but tidy it up in Cloudflare.
312
+
313
+ Side effect worth knowing: if an API key's `siteSlugs` was only that one site,
314
+ deleting it leaves that key with an empty `siteSlugs` array — which the API treats
315
+ as **unrestricted** (every site), not inert. A key scoped to exactly the site you
316
+ delete comes out of it more powerful, not revoked.
317
+
318
+ ## `.scribe.yml` field reference
319
+
320
+ Per collection: `name`, `label`, `path`, `format` (`yaml-frontmatter` for normal
321
+ content; also
322
+ `json-frontmatter`/`toml-frontmatter`/`yaml`/`json`/`toml`/`datagrid`/`code`/`raw`),
323
+ `fields[]`, `view` (`{primary, sort, fields, search, default}` — controls the admin
324
+ list view), and the Scribe extensions `description` (shown under the title on the
325
+ collection's list/inbox page in the admin), `form: true` (renders as an inbox),
326
+ `workflow: {states}` (form collections only), `notify: [...]` (recipients on new
327
+ submissions).
328
+
329
+ On a **form collection** specifically, since RFC 0004 moved submissions into Mongo:
330
+
331
+ - **Many per site is supported and normal.** Every `form: true` collection is an
332
+ independent inbox with its own workflow, badge and `?collection=` value; a schema
333
+ with two of them needs no special handling. The rate limiter is the one shared
334
+ thing — it's keyed per Site and IP, not per form.
335
+ - **Submitted values are coerced to the declared type** before validation, so a
336
+ form posting `FormData` works: a checkbox's `"on"` becomes `true`, `"4"` becomes
337
+ `4`. A value that can't be coerced still fails with a readable message. JSON
338
+ bodies, url-encoded and multipart are all accepted.
339
+ - `path` and `format` are **ignored** — there are no files to lay out. Leave them;
340
+ they're harmless, and the parser doesn't read them for a form collection.
341
+ - `workflow` takes `{states: [...]}` only. The old `field:` key is gone, and so is the
342
+ legacy shorthand where `states` was a list of bare strings — a schema still written
343
+ that way now fails to parse rather than being silently guessed at. Each state is an
344
+ object: `{name, label, kind, hidden}`, where `kind` is `needs-you` | `waiting` |
345
+ `closed` (it drives the inbox's nav badge, which counts `needs-you`) and `hidden`
346
+ keeps a state like `spam` out of the default view while leaving it selectable.
347
+ - **`status` and `createdAt` are reserved field names** and can't be declared in
348
+ `fields[]`. `status` is a real column on the submission and `submittedAt` replaces
349
+ `createdAt`; both render as columns the schema doesn't have to mention.
350
+ - **An `image` field is rejected** on a form collection. The old path uploaded a
351
+ Visitor's file straight into the public repo, and a Mongo document is the wrong place
352
+ for a blob. Deferred to RFC 0007 (object storage).
353
+ - **`notify:` does not send email.** It runs, but through a `ConsoleMailer` that only
354
+ logs — no address has ever received anything. Real delivery is RFC 0009. Don't
355
+ promise a client they'll be emailed on a new submission; the inbox and its
356
+ `needs-you` badge are the actual notification today.
357
+
358
+ Per field: `name`, `type`, `label`, `required`, `default`, `pattern`, `hidden`,
359
+ `readonly`, `isBody` (this field is the markdown body, not frontmatter), `list`
360
+ (`true` or `{min, max, collapsible}`), `options` (for `type: select`). For
361
+ `type: image` fields specifically, see **Images** above — its value (or each
362
+ value in the array, if `list`) must be a committed `/uploads/...` path, not an
363
+ external URL.
364
+
365
+ **Field `type` — stick to the implemented list.** The admin has a working editor
366
+ for: `string`, `text`, `rich-text`, `number`, `boolean`, `date`, `image`, `select`.
367
+ The type enum also accepts `block`, `code`, `file`, `object`, `reference`, `uuid` —
368
+ these parse fine but the admin has no editor for them yet, so a field using one
369
+ falls back to a raw/unstyled view. Don't reach for them unless that's specifically
370
+ acceptable for this site.
371
+
372
+ **Quote date- and time-like values in `string` fields.** When hand-writing a
373
+ content file, `when: 2026-09-28` or `at: 12:30` is read by Astro's YAML loader as
374
+ a Date and a number, not a string — so a field declared `type: string` fails its
375
+ generated `z.string()` schema and **the site build breaks**. Write
376
+ `when: "2026-09-28"`. Fields declared `type: date` are immune (they generate
377
+ `z.coerce.date()`, which takes either form), and anything written by the admin or
378
+ by `scribe content publish` is quoted for you. This only bites on raw hand-edited
379
+ files committed with `git push`.
380
+
381
+ ## Gotchas
382
+
383
+ - Schema changes are instant locally, but cached **8 minutes** in the admin/API
384
+ after a push — don't chase "why hasn't this shown up yet" faster than that.
385
+ - `scribe site clone` is a thin `git clone` wrapper — once cloned, it's an ordinary
386
+ git repo, nothing more.
387
+ - A raw `git push` of content skips schema validation; `scribe content publish`
388
+ doesn't.
389
+ - There's no merge-conflict tooling anywhere in the CLI — resolve like any git
390
+ conflict.
@@ -0,0 +1,31 @@
1
+ # Recipes
2
+
3
+ Patterns for building an Astro site against Scribe as the backend. Per
4
+ [RFC 0010](../../../docs/proposals/0010-agent-authored-forms.md) these are
5
+ **advice, not enforcement** — read the one you need, adapt it to the site you're
6
+ building, and ignore the rest. There is no template to conform to and no
7
+ generated config to keep in sync.
8
+
9
+ | Recipe | Use it when |
10
+ |---|---|
11
+ | [`schema.md`](./schema.md) | you need to know what collections and fields a Site has |
12
+ | [`collections.md`](./collections.md) | rendering content (posts, products, events) that editors manage in Scribe |
13
+ | [`forms.md`](./forms.md) | putting one or more forms on the Site |
14
+ | [`deploy.md`](./deploy.md) | setting up or debugging the build and deploy |
15
+
16
+ ## The part that isn't advice
17
+
18
+ Scribe doesn't only serve the Site — it **writes into the Site's repo** and
19
+ triggers its build. The admin commits content files and uploaded images, writes
20
+ `public/CNAME` when a domain is verified, and `scribe build trigger` dispatches
21
+ the deploy workflow. So these five things are a contract, not a preference:
22
+
23
+ 1. Content lives at each collection's `path`, in its `format`.
24
+ 2. Images live under `public/` at the path stored in the item's frontmatter.
25
+ 3. The presence of `public/CNAME` decides Astro's `base`.
26
+ 4. The deploy workflow accepts `workflow_dispatch`.
27
+ 5. Forms POST to `/api/submissions?site=<slug>&collection=<name>`.
28
+
29
+ Get one of these wrong and the failure shows up *after* an editor hits publish,
30
+ on the live site, not while you're building. Everything else — routing, styling,
31
+ page structure, which pages exist, where a form goes — is yours.