@scribe-platform/cli 0.4.0 → 0.5.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
@@ -32,6 +32,21 @@ It defaults to the hosted admin at `https://scribe.weaverdigital.io` — pass
32
32
  scribe login --token <key> --api-url http://localhost:3000
33
33
  ```
34
34
 
35
+ ## Update
36
+
37
+ ```
38
+ scribe update
39
+ ```
40
+
41
+ Installs the latest `@scribe-platform/cli` and copies the bundled
42
+ `scribe-site-builder` Claude Code skill into `~/.claude/skills/`, so the CLI and
43
+ the instructions Claude reads move together. Restart Claude Code afterwards — it
44
+ only discovers skills at session start.
45
+
46
+ Requires a global npm install; it refuses to guess for a checkout, `npx`, or
47
+ another package manager. A symlinked `~/.claude/skills/scribe-site-builder` is
48
+ left untouched, so a dev checkout's live skill isn't replaced by a copy.
49
+
35
50
  ## Commands
36
51
 
37
52
  Run `scribe <group> --help` for the full options on any of these.
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ export declare function registerUpdateCommand(program: Command): void;
3
+ //# sourceMappingURL=update.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update.d.ts","sourceRoot":"","sources":["../../src/commands/update.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgDpC,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,OAAO,GAAG,IAAI,CAoD5D"}
@@ -0,0 +1,93 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { readFileSync, realpathSync } from 'node:fs';
3
+ import { dirname, join, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { CliExit, runAction } from '../lib/cli-utils.js';
6
+ import { installSkill, tildify } from '../lib/skill.js';
7
+ const PACKAGE_NAME = '@scribe-platform/cli';
8
+ /** This install's own package root — `<global npm root>/@scribe-platform/cli` when installed globally. */
9
+ function packageRoot() {
10
+ return resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
11
+ }
12
+ /** The version recorded on disk right now, which is the new one once `npm install -g` has run. */
13
+ function installedVersion(root) {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf-8'));
16
+ return pkg.version ?? null;
17
+ }
18
+ catch {
19
+ return null;
20
+ }
21
+ }
22
+ function npm(args, inherit) {
23
+ return spawnSync('npm', args, {
24
+ stdio: inherit ? 'inherit' : 'pipe',
25
+ encoding: 'utf-8',
26
+ // npm is a shell script/.cmd on Windows, so it isn't directly executable there.
27
+ shell: process.platform === 'win32',
28
+ });
29
+ }
30
+ /** True when `root` sits inside npm's global package directory — i.e. `npm install -g` owns this install. */
31
+ function isGlobalInstall(root) {
32
+ const result = npm(['root', '-g'], false);
33
+ if (result.status !== 0 || typeof result.stdout !== 'string')
34
+ return false;
35
+ // Both sides get realpath'd: nvm, Homebrew and Volta all hand out globals
36
+ // through symlinked prefixes, so a raw string compare gives false negatives.
37
+ const real = (path) => {
38
+ try {
39
+ return realpathSync(path);
40
+ }
41
+ catch {
42
+ return path;
43
+ }
44
+ };
45
+ const globalRoot = real(result.stdout.trim());
46
+ return real(root).startsWith(globalRoot + sep);
47
+ }
48
+ export function registerUpdateCommand(program) {
49
+ program
50
+ .command('update')
51
+ // Aliased because `scribe site update` already exists: someone reaching for
52
+ // "upgrade the tool" shouldn't hit a dead end over which word we chose.
53
+ .alias('upgrade')
54
+ .description('Update the scribe CLI and the Claude Code skill that ships with it')
55
+ .action(runAction(async () => {
56
+ const root = packageRoot();
57
+ if (!isGlobalInstall(root)) {
58
+ console.error(`This scribe isn't a global npm install — it's running from ${root}.`);
59
+ console.error('Update it the way it was installed. For a normal install that is:');
60
+ console.error(` npm install -g ${PACKAGE_NAME}@latest`);
61
+ process.exitCode = 1;
62
+ throw new CliExit();
63
+ }
64
+ const before = installedVersion(root);
65
+ console.log(`Updating ${PACKAGE_NAME}${before ? ` (currently ${before})` : ''}...`);
66
+ const install = npm(['install', '-g', `${PACKAGE_NAME}@latest`], true);
67
+ if (install.status !== 0) {
68
+ console.error(`\nnpm install -g ${PACKAGE_NAME}@latest failed.`);
69
+ console.error("If that was a permission error (EACCES), fix npm's global prefix or re-run it with sudo.");
70
+ process.exitCode = 1;
71
+ throw new CliExit();
72
+ }
73
+ // npm has replaced this package's files in place. The code already
74
+ // running is the old version, but everything below reads from disk, so
75
+ // the skill installed is the one that ships with the new version.
76
+ const after = installedVersion(root);
77
+ console.log(after && after !== before ? `\nUpdated to ${after}.` : `\nAlready on the latest version${after ? ` (${after})` : ''}.`);
78
+ const skill = installSkill();
79
+ if (skill.status === 'missing-source') {
80
+ console.error("The CLI updated, but its bundled skill wasn't where this version expected it.");
81
+ console.error('Run `scribe update` once more — the newer code knows where to look.');
82
+ process.exitCode = 1;
83
+ return;
84
+ }
85
+ if (skill.status === 'symlink') {
86
+ console.log(`Skill: left ${tildify(skill.dest)} alone — it's a symlink into a checkout.`);
87
+ return;
88
+ }
89
+ console.log(`Skill: installed scribe-site-builder to ${tildify(skill.dest)}.`);
90
+ console.log('\nRestart Claude Code so it picks up the updated skill.');
91
+ }));
92
+ }
93
+ //# sourceMappingURL=update.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"update.js","sourceRoot":"","sources":["../../src/commands/update.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAepC,wBAAgB,aAAa,IAAI,OAAO,CAevC;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAgBpC,wBAAgB,aAAa,IAAI,OAAO,CAgBvC;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { registerContentCommands } from './commands/content.js';
6
6
  import { registerSubmissionCommands } from './commands/submission.js';
7
7
  import { registerSchemaCommands } from './commands/schema.js';
8
8
  import { registerBuildCommands } from './commands/build.js';
9
+ import { registerUpdateCommand } from './commands/update.js';
9
10
  // Read from package.json at runtime rather than hardcoding: `../package.json`
10
11
  // resolves to the package root from both `dist/index.js` (published) and
11
12
  // `src/index.ts` (tests), so `scribe --version` can't drift from the release.
@@ -22,6 +23,7 @@ export function createProgram() {
22
23
  registerSubmissionCommands(program);
23
24
  registerSchemaCommands(program);
24
25
  registerBuildCommands(program);
26
+ registerUpdateCommand(program);
25
27
  return program;
26
28
  }
27
29
  export { ScribeClient, ScribeApiError } from './lib/client.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAE5D,8EAA8E;AAC9E,yEAAyE;AACzE,8EAA8E;AAC9E,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,CAC5B,YAAY,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,CAC5C,CAAC;AAEzB,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAC9B,OAAO;SACJ,IAAI,CAAC,QAAQ,CAAC;SACd,WAAW,CAAC,uCAAuC,CAAC;SACpD,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9B,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9B,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACjC,0BAA0B,CAAC,OAAO,CAAC,CAAC;IACpC,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAChC,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAE/B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AACvC,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AACpC,OAAO,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAC1D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,OAAO,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AACtE,OAAO,EAAE,sBAAsB,EAAE,MAAM,sBAAsB,CAAC;AAC9D,OAAO,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,qBAAqB,EAAE,MAAM,sBAAsB,CAAC;AAE7D,8EAA8E;AAC9E,yEAAyE;AACzE,8EAA8E;AAC9E,MAAM,EAAE,OAAO,EAAE,GAAG,IAAI,CAAC,KAAK,CAC5B,YAAY,CAAC,IAAI,GAAG,CAAC,iBAAiB,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,OAAO,CAAC,CAC5C,CAAC;AAEzB,MAAM,UAAU,aAAa;IAC3B,MAAM,OAAO,GAAG,IAAI,OAAO,EAAE,CAAC;IAC9B,OAAO;SACJ,IAAI,CAAC,QAAQ,CAAC;SACd,WAAW,CAAC,uCAAuC,CAAC;SACpD,OAAO,CAAC,OAAO,CAAC,CAAC;IAEpB,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9B,oBAAoB,CAAC,OAAO,CAAC,CAAC;IAC9B,uBAAuB,CAAC,OAAO,CAAC,CAAC;IACjC,0BAA0B,CAAC,OAAO,CAAC,CAAC;IACpC,sBAAsB,CAAC,OAAO,CAAC,CAAC;IAChC,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAC/B,qBAAqB,CAAC,OAAO,CAAC,CAAC;IAE/B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED,OAAO,EAAE,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAC/D,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,45 @@
1
+ /** The Claude Code skill this package ships, as both its directory and skill name. */
2
+ export declare const SKILL_NAME = "scribe-site-builder";
3
+ /** Where Claude Code discovers globally-installed skills. */
4
+ export declare function skillDestination(): string;
5
+ /**
6
+ * The skill bundled with this install, or null if it can't be found. Two
7
+ * candidates, tried in order:
8
+ *
9
+ * 1. `<package>/skills/<name>` — a published tarball, where `prepack`
10
+ * (scripts/bundle-skill.mjs) copied the repo's skill in.
11
+ * 2. `<repo>/skills/<name>` — a dev checkout, where the skill only exists at
12
+ * the monorepo root.
13
+ *
14
+ * Both are the same number of levels up from this file whether it runs as
15
+ * `src/lib/skill.ts` (tests) or `dist/lib/skill.js` (published).
16
+ *
17
+ * Resolved at call time, on purpose: `scribe update` calls this *after* `npm
18
+ * install -g` has replaced this package's files, so it reads the skill that
19
+ * ships with the version just installed rather than the one the running process
20
+ * started with.
21
+ */
22
+ export declare function bundledSkillPath(): string | null;
23
+ export type SkillInstallResult = {
24
+ status: 'installed';
25
+ dest: string;
26
+ } | {
27
+ status: 'symlink';
28
+ dest: string;
29
+ } | {
30
+ status: 'missing-source';
31
+ };
32
+ /**
33
+ * Copies the bundled skill into ~/.claude/skills, replacing whatever is there.
34
+ *
35
+ * A **symlink** at the destination is left untouched: that's a dev checkout
36
+ * pointing at the repo's own skills/ directory, and overwriting it with a copy
37
+ * would silently detach the author's live editing target.
38
+ */
39
+ export declare function installSkill(options?: {
40
+ source?: string | null;
41
+ dest?: string;
42
+ }): SkillInstallResult;
43
+ /** Shortens a path for display, so output reads `~/.claude/skills/...`. */
44
+ export declare function tildify(path: string): string;
45
+ //# sourceMappingURL=skill.d.ts.map
@@ -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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scribe-platform/cli",
3
- "version": "0.4.0",
3
+ "version": "0.5.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,373 @@
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). `scribe build trigger my-site` dispatches
148
+ the same workflow on demand without a new commit — debounced to once per 5
149
+ minutes per site, so repeat calls in that window are a silent no-op.
150
+
151
+ 8. **Domain cutover — only when a customer's own hostname is ready.** Skip this
152
+ entirely for demo sites; the GitHub Pages default already works.
153
+
154
+ Check what DNS the domain needs first. This changes nothing:
155
+ ```
156
+ scribe site promote my-site --domain example.com --dns-only
157
+ ```
158
+ It prints the records to add at the registrar. An apex domain needs the four A
159
+ records; a subdomain needs only the CNAME. **In Cloudflare the record must be
160
+ DNS-only (grey cloud), not proxied** — the orange-cloud proxy blocks the
161
+ challenge GitHub uses to issue the site's certificate.
162
+
163
+ Once the records are in place and resolving:
164
+ ```
165
+ scribe site promote my-site --domain example.com
166
+ ```
167
+ This does the whole cutover in one go: writes `public/CNAME`, registers the
168
+ domain with GitHub Pages, points the contact form at the admin app, updates the
169
+ hostname the public form endpoint accepts submissions from, adds the domain to
170
+ the Turnstile widget, enforces HTTPS, and redeploys.
171
+
172
+ It **refuses to run until DNS actually points at GitHub Pages**, because the
173
+ cutover kills the old project-page URL — if DNS isn't ready the site is
174
+ unreachable at both addresses. Fix the DNS and run it again rather than reaching
175
+ for `--force`.
176
+
177
+ **Re-running is safe and is the fix for anything that goes wrong.** Every step
178
+ checks the current state before writing, so a run that fails halfway is repaired
179
+ by running the same command again. If it reports that GitHub is still issuing
180
+ the certificate, wait a few minutes and re-run to finish — until then `http://`
181
+ won't serve the site.
182
+
183
+ If it prints a `Warning:` about the Turnstile widget, the domain list did **not**
184
+ sync — fix it in the Cloudflare dashboard, or the contact form silently rejects
185
+ real visitors on that domain.
186
+
187
+ `scribe site update my-site --domain example.com` only edits the stored record
188
+ and the Turnstile widget. Use it to correct a domain, never to cut a site over.
189
+
190
+ ## Working an existing site
191
+
192
+ ```
193
+ scribe content list <site> [--collection <name>] [--json]
194
+ scribe content read <site> <path> [--json]
195
+ scribe content publish <site> <file> # validates against schema
196
+ scribe content delete <site> <path>
197
+ scribe content versions <site> <path> [--json] # commit history
198
+ scribe content revert <site> <path> --sha <sha> # checks out old version as new commit
199
+
200
+ scribe schema show <site> [collection] [--json] # read the schema
201
+ scribe schema apply <site> --file <spec|-> # create/replace a whole collection
202
+ scribe schema add <site> <collection-name> [--path <path>] # bare stub only, no fields
203
+ scribe schema remove <site> <collection-name> # deletes its folder too
204
+
205
+ scribe submission list <site> <collection> [--status <status>] [--page <n>] [--json]
206
+ scribe submission update <site> <collection> <id> [--status <s>] [--note <n>]
207
+ scribe submission export <site> <collection> [--format csv|json] [--status <s>] [--out <file>]
208
+ scribe submission delete <site> <collection> <id> [--yes] # staff only
209
+ ```
210
+
211
+ - `scribe schema apply` is the way to add a form (or any collection with fields): it
212
+ takes a YAML/JSON file describing **one whole collection**, upserts it by `name`,
213
+ and validates through the admin's own parser first — so a reserved `status` field,
214
+ an `image` field on a form collection, or a malformed workflow is reported before
215
+ anything is committed. `--file -` reads from a pipe. Full worked example in
216
+ [`recipes/forms.md`](./recipes/forms.md).
217
+ - `scribe schema add` only appends a bare `{name, path}` stub with no fields; it's for
218
+ starting a content collection interactively. There is still no *field*-level
219
+ command — `schema apply` replaces the whole collection, so include every field.
220
+ - `scribe content publish` reads a **local file at the given repo-relative path**,
221
+ parsing `---`-delimited YAML/JSON frontmatter (or a bare YAML/JSON object with no
222
+ body).
223
+ - `scribe submission list` paginates (1-based `--page`); `--json` gives the whole page
224
+ object, so `total` tells you whether there's a page 2.
225
+ - **`<id>` is a Mongo ObjectId**, as printed by `submission list` — not a filename and
226
+ not a repo path. There's no path inference any more; submissions aren't files.
227
+ - `--note` **appends** to the submission's history. It never overwrites an earlier
228
+ note, and it leaves the submission's own `updatedAt` alone — a note is something
229
+ someone wrote *about* the lead, not a change to it.
230
+ - `submission export` writes to stdout unless `--out` is given, so it pipes
231
+ (`scribe submission export my-site inquiries > leads.csv`). CSV is for a
232
+ spreadsheet and is lossy on purpose: its columns come from the collection's schema,
233
+ so the header row is identical between exports even when a batch left a field blank,
234
+ and it carries no history and no visitor IP or user agent. `--format json` is
235
+ lossless, with each submission's events nested under it.
236
+ - `submission delete` is permanent, cascades the submission's history, and needs a
237
+ **staff** role — a site user's API key gets a 403. It prompts for the id unless
238
+ `--yes` is passed. There is deliberately no equivalent in the admin UI, so don't go
239
+ looking for a delete button on the inbox.
240
+
241
+ ## Images — never hotlink, always commit the file
242
+
243
+ **A `type: image` field is just a string.** Nothing in Scribe validates that its
244
+ value is an uploaded image — the schema doesn't know or care whether you write
245
+ `/uploads/some-photo.jpg` or `https://images.unsplash.com/photo-xyz`. When you (the
246
+ agent) are hand-authoring content files directly in a cloned repo — which is the
247
+ normal CLI workflow — **do not put an external image URL (Unsplash or otherwise)
248
+ into an image field's value.** It looks fine at first glance and then quietly means
249
+ the site depends forever on a third-party host it doesn't control, the image was
250
+ never actually reviewed/chosen for that specific listing, and it breaks the "this
251
+ site's content lives in this repo" model the rest of Scribe assumes.
252
+
253
+ Instead, every image an agent puts into content must be a **real file committed to
254
+ the site's own repo**, the same way the admin's own upload feature does it:
255
+
256
+ 1. Get the actual image bytes — download it (a real stock photo, something the
257
+ user supplied, whatever the source) to a local temp file.
258
+ 2. Save it into the cloned repo at `public/uploads/<name>`, with a name unique
259
+ enough not to collide (mirror the admin's own convention:
260
+ `<timestamp>-<safe-filename>`, letters/digits/`._-` only).
261
+ 3. Reference it in the content file's frontmatter as `/uploads/<name>` — a
262
+ repo-relative path, not the original external URL.
263
+ 4. Commit and push (`public/uploads/...` + the content file) together, same as any
264
+ other content change. There's no `scribe` CLI command for this — it's a plain
265
+ file in the repo, same as everything else.
266
+
267
+ This applies to `list: true` image fields too — one committed file per array entry,
268
+ same convention. A single-image field (`type: image`, no `list`) and a list one both
269
+ end up as `/uploads/...` paths; only the shape (string vs. array of strings)
270
+ differs.
271
+
272
+ The one case where a bare external URL is fine: the user explicitly says to leave a
273
+ placeholder / link out to somewhere else on purpose. Don't default to that — treat
274
+ "add a photo of X" as "go get a real image and commit it," not "find any URL that
275
+ renders."
276
+
277
+ ## Deleting a site
278
+
279
+ ```
280
+ scribe site delete <slug> [--yes]
281
+ ```
282
+
283
+ Permanent — deletes the GitHub repo (schema, content, Astro source) and every Mongo
284
+ record for the site, which since RFC 0004 includes its submissions and their event
285
+ history. No undo. Prompts to retype the
286
+ slug unless `--yes` is passed. Requires an **unrestricted** API key (no
287
+ `siteSlugs` restriction) — same rule as `site add`; a key scoped to only that
288
+ site is rejected. Same action is in the admin UI: account menu → **Settings**
289
+ (super-admin only) → **Danger zone**.
290
+
291
+ Deleting also drops the site's domain from the Turnstile widget — but only if no
292
+ other site still uses that domain, so removing one demo never breaks the contact
293
+ form on the rest. A `Warning:` in the output means that cleanup didn't happen and
294
+ the stale domain is still on the widget; harmless, but tidy it up in Cloudflare.
295
+
296
+ Side effect worth knowing: if an API key's `siteSlugs` was only that one site,
297
+ deleting it leaves that key with an empty `siteSlugs` array — which the API treats
298
+ as **unrestricted** (every site), not inert. A key scoped to exactly the site you
299
+ delete comes out of it more powerful, not revoked.
300
+
301
+ ## `.scribe.yml` field reference
302
+
303
+ Per collection: `name`, `label`, `path`, `format` (`yaml-frontmatter` for normal
304
+ content; also
305
+ `json-frontmatter`/`toml-frontmatter`/`yaml`/`json`/`toml`/`datagrid`/`code`/`raw`),
306
+ `fields[]`, `view` (`{primary, sort, fields, search, default}` — controls the admin
307
+ list view), and the Scribe extensions `description` (shown under the title on the
308
+ collection's list/inbox page in the admin), `form: true` (renders as an inbox),
309
+ `workflow: {states}` (form collections only), `notify: [...]` (recipients on new
310
+ submissions).
311
+
312
+ On a **form collection** specifically, since RFC 0004 moved submissions into Mongo:
313
+
314
+ - **Many per site is supported and normal.** Every `form: true` collection is an
315
+ independent inbox with its own workflow, badge and `?collection=` value; a schema
316
+ with two of them needs no special handling. The rate limiter is the one shared
317
+ thing — it's keyed per Site and IP, not per form.
318
+ - **Submitted values are coerced to the declared type** before validation, so a
319
+ form posting `FormData` works: a checkbox's `"on"` becomes `true`, `"4"` becomes
320
+ `4`. A value that can't be coerced still fails with a readable message. JSON
321
+ bodies, url-encoded and multipart are all accepted.
322
+ - `path` and `format` are **ignored** — there are no files to lay out. Leave them;
323
+ they're harmless, and the parser doesn't read them for a form collection.
324
+ - `workflow` takes `{states: [...]}` only. The old `field:` key is gone, and so is the
325
+ legacy shorthand where `states` was a list of bare strings — a schema still written
326
+ that way now fails to parse rather than being silently guessed at. Each state is an
327
+ object: `{name, label, kind, hidden}`, where `kind` is `needs-you` | `waiting` |
328
+ `closed` (it drives the inbox's nav badge, which counts `needs-you`) and `hidden`
329
+ keeps a state like `spam` out of the default view while leaving it selectable.
330
+ - **`status` and `createdAt` are reserved field names** and can't be declared in
331
+ `fields[]`. `status` is a real column on the submission and `submittedAt` replaces
332
+ `createdAt`; both render as columns the schema doesn't have to mention.
333
+ - **An `image` field is rejected** on a form collection. The old path uploaded a
334
+ Visitor's file straight into the public repo, and a Mongo document is the wrong place
335
+ for a blob. Deferred to RFC 0007 (object storage).
336
+ - **`notify:` does not send email.** It runs, but through a `ConsoleMailer` that only
337
+ logs — no address has ever received anything. Real delivery is RFC 0009. Don't
338
+ promise a client they'll be emailed on a new submission; the inbox and its
339
+ `needs-you` badge are the actual notification today.
340
+
341
+ Per field: `name`, `type`, `label`, `required`, `default`, `pattern`, `hidden`,
342
+ `readonly`, `isBody` (this field is the markdown body, not frontmatter), `list`
343
+ (`true` or `{min, max, collapsible}`), `options` (for `type: select`). For
344
+ `type: image` fields specifically, see **Images** above — its value (or each
345
+ value in the array, if `list`) must be a committed `/uploads/...` path, not an
346
+ external URL.
347
+
348
+ **Field `type` — stick to the implemented list.** The admin has a working editor
349
+ for: `string`, `text`, `rich-text`, `number`, `boolean`, `date`, `image`, `select`.
350
+ The type enum also accepts `block`, `code`, `file`, `object`, `reference`, `uuid` —
351
+ these parse fine but the admin has no editor for them yet, so a field using one
352
+ falls back to a raw/unstyled view. Don't reach for them unless that's specifically
353
+ acceptable for this site.
354
+
355
+ **Quote date- and time-like values in `string` fields.** When hand-writing a
356
+ content file, `when: 2026-09-28` or `at: 12:30` is read by Astro's YAML loader as
357
+ a Date and a number, not a string — so a field declared `type: string` fails its
358
+ generated `z.string()` schema and **the site build breaks**. Write
359
+ `when: "2026-09-28"`. Fields declared `type: date` are immune (they generate
360
+ `z.coerce.date()`, which takes either form), and anything written by the admin or
361
+ by `scribe content publish` is quoted for you. This only bites on raw hand-edited
362
+ files committed with `git push`.
363
+
364
+ ## Gotchas
365
+
366
+ - Schema changes are instant locally, but cached **8 minutes** in the admin/API
367
+ after a push — don't chase "why hasn't this shown up yet" faster than that.
368
+ - `scribe site clone` is a thin `git clone` wrapper — once cloned, it's an ordinary
369
+ git repo, nothing more.
370
+ - A raw `git push` of content skips schema validation; `scribe content publish`
371
+ doesn't.
372
+ - There's no merge-conflict tooling anywhere in the CLI — resolve like any git
373
+ 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.
@@ -0,0 +1,87 @@
1
+ # Recipe: rendering content collections
2
+
3
+ A content collection (anything without `form: true`) is a folder of files the
4
+ admin writes directly into the repo. Your job is to read them from the `path` the
5
+ schema declares, in the `format` it declares. That part isn't negotiable — the
6
+ admin commits those files and expects them there. How you route, style and lay
7
+ them out is entirely yours.
8
+
9
+ ## The two formats
10
+
11
+ - **`yaml-frontmatter`** — markdown: `---` frontmatter, then a body. The body is
12
+ whichever field has `isBody: true`.
13
+ - **`yaml`** — a plain `.yml` data file, no body.
14
+
15
+ Files are named `<slug>.md` / `<slug>.yml`, and the slug is the URL slug.
16
+
17
+ ## With Astro content collections
18
+
19
+ Glob the schema's paths. Astro wants its own `src/content.config.ts`, so keep the
20
+ loader list in step with `.scribe.yml` rather than hardcoding one Site's names:
21
+
22
+ ```ts
23
+ // src/content.config.ts
24
+ import { defineCollection, z } from 'astro:content';
25
+ import { glob } from 'astro/loaders';
26
+
27
+ const cars = defineCollection({
28
+ loader: glob({ pattern: '**/*.md', base: './content/cars' }),
29
+ // Loose on purpose: an editor adding a field in Scribe shouldn't fail the
30
+ // build before you've had a chance to render it.
31
+ schema: z.object({
32
+ title: z.string(),
33
+ make: z.string().optional(),
34
+ year: z.number().optional(),
35
+ price: z.number().optional(),
36
+ images: z.array(z.string()).default([]),
37
+ active: z.boolean().default(true),
38
+ }).passthrough(),
39
+ });
40
+
41
+ export const collections = { cars };
42
+ ```
43
+
44
+ Then render as usual with `getCollection('cars')`.
45
+
46
+ Keep the zod schema **permissive** (`.passthrough()`, `.optional()`). A strict
47
+ schema turns "an editor added a field" into a failed deploy, and the editor has
48
+ no way to see why.
49
+
50
+ ## Images
51
+
52
+ Uploaded images are committed under `public/` and the frontmatter stores the
53
+ **absolute site path** — `/images/cars/bugatti-chiron.jpg`. Two consequences:
54
+
55
+ - Don't use `astro:assets` `Image` on them; they aren't in `src/`. A plain `<img
56
+ src={...}>` is correct.
57
+ - On a project page (no custom domain) the Astro `base` is `/<repo>`, so prefix
58
+ stored paths with it or they 404 in the demo:
59
+
60
+ ```astro
61
+ ---
62
+ const base = import.meta.env.BASE_URL.replace(/\/$/, '');
63
+ ---
64
+ <img src={`${base}${car.data.images[0]}`} alt={car.data.title} />
65
+ ```
66
+
67
+ This is the single most common way a demo site looks broken while the custom
68
+ domain version looks fine.
69
+
70
+ ## Don't hardcode one Client's collections
71
+
72
+ The same code gets reused across Sites whose collections are named differently.
73
+ Drive pages off the schema where it's cheap:
74
+
75
+ ```astro
76
+ ---
77
+ import { contentCollections } from '../lib/scribe';
78
+ const sections = contentCollections().filter((c) => c.name !== 'posts');
79
+ ---
80
+ {sections.map((c) => <a href={`/${c.name}`}>{c.label ?? c.name}</a>)}
81
+ ```
82
+
83
+ ## Local content
84
+
85
+ `scribe content pull <site>` fetches the live content into the repo so you can
86
+ build against real data. A Site also usually keeps a `content/` folder committed,
87
+ which is what the deploy builds from.
@@ -0,0 +1,122 @@
1
+ # Recipe: build and deploy
2
+
3
+ Sites deploy to GitHub Pages from their own repo. Two parts of this are a
4
+ contract with Scribe rather than a preference: the workflow must accept
5
+ `workflow_dispatch`, and the Astro `base` must follow `public/CNAME`.
6
+
7
+ ## Why `workflow_dispatch` matters
8
+
9
+ When an editor publishes in the admin, Scribe commits the content file and then
10
+ calls `scribe build trigger` → `dispatchWorkflow`. Without
11
+ `workflow_dispatch: {}` the dispatch fails and **the editor's change never goes
12
+ live** even though the admin says it saved. Keep it even if you also build on
13
+ push.
14
+
15
+ ## Why the base path follows CNAME
16
+
17
+ A bare project page lives at `https://<org>.github.io/<repo>/`, so Astro needs
18
+ `base: '/<repo>'`. Once a custom domain is verified, the admin writes
19
+ `public/CNAME` and the site serves from the root, so `base` must become `/`.
20
+ Deciding this in the workflow means a domain going live needs no code change.
21
+
22
+ ```yaml
23
+ # .github/workflows/deploy.yml
24
+ name: Deploy to GitHub Pages
25
+
26
+ on:
27
+ push:
28
+ branches: [main]
29
+ workflow_dispatch: {} # required — Scribe dispatches this on publish
30
+
31
+ permissions:
32
+ contents: read
33
+ pages: write
34
+ id-token: write
35
+
36
+ concurrency:
37
+ group: pages
38
+ cancel-in-progress: true
39
+
40
+ jobs:
41
+ build:
42
+ runs-on: ubuntu-latest
43
+ steps:
44
+ - uses: actions/checkout@v4
45
+ - uses: actions/setup-node@v4
46
+ with:
47
+ node-version: 22
48
+ - run: npm install
49
+
50
+ - name: Compute Pages base path
51
+ id: base
52
+ run: |
53
+ if [ -f public/CNAME ]; then
54
+ echo "path=/" >> "$GITHUB_OUTPUT"
55
+ else
56
+ echo "path=/${{ github.event.repository.name }}" >> "$GITHUB_OUTPUT"
57
+ fi
58
+
59
+ - run: npm run build
60
+ env:
61
+ PUBLIC_SCRIBE_API_URL: ${{ vars.PUBLIC_SCRIBE_API_URL }}
62
+ PUBLIC_BASE_PATH: ${{ steps.base.outputs.path }}
63
+
64
+ - uses: actions/upload-pages-artifact@v3
65
+ with:
66
+ path: dist
67
+
68
+ deploy:
69
+ needs: build
70
+ runs-on: ubuntu-latest
71
+ environment:
72
+ name: github-pages
73
+ steps:
74
+ - id: deployment
75
+ uses: actions/deploy-pages@v4
76
+ ```
77
+
78
+ And in `astro.config.mjs`:
79
+
80
+ ```js
81
+ export default defineConfig({
82
+ base: process.env.PUBLIC_BASE_PATH || '/',
83
+ // ...
84
+ });
85
+ ```
86
+
87
+ ## `PUBLIC_SCRIBE_API_URL`
88
+
89
+ Where forms post. It's a **repo variable**, and GitHub resolves an unset
90
+ `vars.*` to an empty string rather than leaving it undefined — so `??` won't
91
+ fall back. Use `||`:
92
+
93
+ ```ts
94
+ const apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io';
95
+ ```
96
+
97
+ Any `PUBLIC_*` value is inlined into the client bundle at build time, so changing
98
+ it needs a rebuild, not just a redeploy.
99
+
100
+ ## Local loop
101
+
102
+ ```
103
+ npm install
104
+ npm run dev # iterate on design
105
+ npm run build && npm run preview
106
+ ```
107
+
108
+ To test forms locally, point `PUBLIC_SCRIBE_API_URL` at a local admin
109
+ (`http://localhost:3040`) — but note the endpoint only allows CORS from the
110
+ Site's registered domain, so use the admin's own host or expect to test
111
+ submissions against the deployed site.
112
+
113
+ ## Checklist when a deploy looks wrong
114
+
115
+ - Images 404 on the demo URL but work on the custom domain → missing `base`
116
+ prefix (see `collections.md`).
117
+ - Editor published but the site is stale → `workflow_dispatch` missing, or the
118
+ workflow failed; check the Actions tab.
119
+ - Form says "Failed to fetch" → `PUBLIC_SCRIBE_API_URL` baked in wrong, or the
120
+ origin isn't the Site's registered domain.
121
+ - Build fails after an editor added a field → the content zod schema is too
122
+ strict (see `collections.md`).
@@ -0,0 +1,231 @@
1
+ # Recipe: forms
2
+
3
+ A Site can have **as many forms as it likes**. Each one is a collection in
4
+ `.scribe.yml` with `form: true`, each gets its own inbox and its own workflow in
5
+ the admin, and each posts to the same endpoint with a different `collection`.
6
+
7
+ Design the form however you want. Only the POST contract below is fixed.
8
+
9
+ ## 1. Declare the collection
10
+
11
+ Write a spec file and apply it — don't hand-edit `.scribe.yml` in the repo, since
12
+ the admin treats the committed file as the source of truth and will overwrite
13
+ you:
14
+
15
+ ```yaml
16
+ # rsvps.yml
17
+ name: rsvps
18
+ label: Event RSVPs
19
+ description: Replies to the invitations on the events page.
20
+ path: content/rsvps
21
+ format: yaml
22
+ form: true
23
+ fields:
24
+ - { name: fullName, type: string, required: true }
25
+ - { name: email, type: string, required: true }
26
+ - { name: mobile, type: string, label: Mobile }
27
+ - { name: eventTitle, type: string, label: Event, required: true }
28
+ - { name: guests, type: number }
29
+ - { name: attending, type: boolean, label: I'll be there }
30
+ - { name: dietaryNotes, type: text }
31
+ workflow:
32
+ states:
33
+ - { name: pending, label: Awaiting reply, kind: needs-you }
34
+ - { name: waitlist, label: Waitlisted, kind: waiting }
35
+ - { name: confirmed, label: Confirmed, kind: closed }
36
+ - { name: declined, label: Declined, kind: closed }
37
+ ```
38
+
39
+ ```
40
+ scribe schema apply <site> --file rsvps.yml # or --file - to pipe it in
41
+ scribe schema show <site> rsvps # read it back
42
+ ```
43
+
44
+ Rules the apply will enforce for you:
45
+
46
+ - `status` and `createdAt` are **reserved** — they're columns on the submission,
47
+ not fields you declare.
48
+ - A form collection may **not** declare an `image` field; a visitor-submitted
49
+ file has nowhere to be stored yet (RFC 0007).
50
+ - `workflow.states` is a list of `{ name, label, kind }` where `kind` is
51
+ `needs-you`, `waiting` or `closed`. A new submission starts in the first
52
+ `needs-you` state, so order matters. `hidden: true` keeps a state out of the
53
+ filter dropdown.
54
+ - `path` and `format` are ignored for a form collection (submissions live in the
55
+ database), but the parser still wants them.
56
+
57
+ ## 2. POST contract
58
+
59
+ ```
60
+ POST /api/submissions?site=<slug>&collection=<name>
61
+ ```
62
+
63
+ - **Body:** JSON, or `application/x-www-form-urlencoded`, or
64
+ `multipart/form-data`. Values are coerced to the declared types, so a checkbox
65
+ posting `"on"` becomes `true` and `"4"` becomes `4`. Posting real JSON types is
66
+ still better — it's unambiguous.
67
+ - **Turnstile is required.** Send the widget's token as `cf-turnstile-response`.
68
+ A missing or empty token is a **401**, always — there is no bypass.
69
+ - **Responses:** `200 {ok:true}`; `400` with a human-readable `error` for a
70
+ validation failure; `401` verification failed; `429` rate limited (per site and
71
+ IP, **shared across all of that Site's forms**); `503` with visitor-facing copy
72
+ in `error` when the database is unreachable — show that message as-is, it's
73
+ written for them; `502` when the Site's `.scribe.yml` is unusable.
74
+ - **CORS** is allowed only from the Site's registered domain, so test from the
75
+ real host (or `localhost` against a local admin).
76
+
77
+ Never report success on a non-2xx. A form that says "Thanks!" after a 503 loses
78
+ the message.
79
+
80
+ ## 3. A form component
81
+
82
+ Generic and schema-driven, which is the useful starting point when the fields
83
+ come from `.scribe.yml`. Adapt or replace the markup freely.
84
+
85
+ ```astro
86
+ ---
87
+ // src/components/ScribeForm.astro
88
+ export interface Props {
89
+ site: string;
90
+ collection: string;
91
+ turnstileSiteKey: string;
92
+ fields: Array<{ name: string; label?: string; type: string; required?: boolean }>;
93
+ apiBase?: string;
94
+ submitLabel?: string;
95
+ /** Values the page knows that the visitor shouldn't retype, e.g. which event this is. */
96
+ prefill?: Record<string, string>;
97
+ }
98
+
99
+ const {
100
+ site, collection, turnstileSiteKey, fields,
101
+ apiBase = import.meta.env.PUBLIC_SCRIBE_API_URL || 'https://scribe.weaverdigital.io',
102
+ submitLabel = 'Send',
103
+ prefill = {},
104
+ } = Astro.props;
105
+
106
+ // Scoped to the collection so two forms can share a page.
107
+ const formId = `scribe-form-${collection}`;
108
+
109
+ function labelFor(f: { name: string; label?: string }) {
110
+ return f.label ?? f.name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ')
111
+ .replace(/^./, (c) => c.toUpperCase());
112
+ }
113
+ // Control comes from the declared type. Name-based guessing is only for the
114
+ // keyboard hint on an email/phone input — never for required-ness or the control.
115
+ function inputType(f: { name: string; type: string }) {
116
+ if (f.type === 'number') return 'number';
117
+ if (/mail/i.test(f.name)) return 'email';
118
+ if (/phone|mobile|tel|cell/i.test(f.name)) return 'tel';
119
+ return 'text';
120
+ }
121
+ ---
122
+ <form id={formId} data-site={site} data-collection={collection} data-endpoint={`${apiBase}/api/submissions`}>
123
+ {fields.map((f) => (
124
+ prefill[f.name] !== undefined ? (
125
+ <input type="hidden" name={f.name} value={prefill[f.name]} />
126
+ ) : f.type === 'boolean' ? (
127
+ <label class="checkbox">
128
+ <input name={f.name} type="checkbox" /> {labelFor(f)}
129
+ </label>
130
+ ) : f.type === 'text' ? (
131
+ <label>{labelFor(f)}<textarea name={f.name} required={f.required} rows="4"></textarea></label>
132
+ ) : (
133
+ <label>{labelFor(f)}<input name={f.name} type={inputType(f)} required={f.required} /></label>
134
+ )
135
+ ))}
136
+ <div class="cf-turnstile" data-sitekey={turnstileSiteKey}></div>
137
+ <button type="submit">{submitLabel}</button>
138
+ <p class="form-status" role="status" aria-live="polite"></p>
139
+ </form>
140
+
141
+ <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
142
+ <script is:inline define:vars={{ formId }}>
143
+ (function () {
144
+ const form = document.getElementById(formId);
145
+ if (!form) return;
146
+ const statusEl = form.querySelector('.form-status');
147
+ const say = (m) => { if (statusEl) statusEl.textContent = m; };
148
+
149
+ form.addEventListener('submit', async (event) => {
150
+ event.preventDefault();
151
+
152
+ // Built per control rather than from FormData, so a checkbox sends a real
153
+ // boolean and an unchecked box sends `false` instead of being omitted.
154
+ const payload = {};
155
+ for (const el of form.elements) {
156
+ if (!el.name) continue;
157
+ if (el.type === 'checkbox') payload[el.name] = el.checked;
158
+ else if (el.type === 'number') payload[el.name] = el.value === '' ? null : Number(el.value);
159
+ else payload[el.name] = el.value;
160
+ }
161
+
162
+ const url = `${form.dataset.endpoint}?site=${encodeURIComponent(form.dataset.site)}`
163
+ + `&collection=${encodeURIComponent(form.dataset.collection)}`;
164
+
165
+ say('Sending…');
166
+ try {
167
+ const res = await fetch(url, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: JSON.stringify(payload),
171
+ });
172
+ if (!res.ok) {
173
+ // The server's copy is written for the visitor on 400/503; prefer it.
174
+ const data = await res.json().catch(() => ({}));
175
+ say(data.error || "Sorry — we couldn't send that. Please try again.");
176
+ return;
177
+ }
178
+ form.reset();
179
+ say('Thanks — we got it.');
180
+ } catch {
181
+ say("Sorry — we couldn't reach us just now. Please call or email instead.");
182
+ }
183
+ });
184
+ })();
185
+ </script>
186
+ ```
187
+
188
+ Note `cf-turnstile-response`: the Turnstile widget injects a hidden input with
189
+ that name into the form, so the loop above picks it up automatically. If you
190
+ build the payload by hand, include it explicitly.
191
+
192
+ ## 4. Put it on a page
193
+
194
+ Placement is entirely yours — the schema says nothing about where a form goes.
195
+ Read the collection out of the schema and pass its fields in:
196
+
197
+ ```astro
198
+ ---
199
+ // src/pages/events/[...slug].astro
200
+ import ScribeForm from '../../components/ScribeForm.astro';
201
+ import { loadScribeSchema } from '../../lib/scribe'; // see schema.md
202
+
203
+ const schema = loadScribeSchema();
204
+ const rsvps = schema.collections.find((c) => c.name === 'rsvps');
205
+ const { event } = Astro.props;
206
+ ---
207
+ <h2>Coming along?</h2>
208
+ <ScribeForm
209
+ site={schema.site}
210
+ collection="rsvps"
211
+ turnstileSiteKey={schema.turnstileSiteKey}
212
+ fields={rsvps.fields}
213
+ submitLabel="Send my RSVP"
214
+ prefill={{ eventTitle: event.data.title }}
215
+ />
216
+ ```
217
+
218
+ `prefill` is how a form on a detail page captures its context — the visitor
219
+ never types which event they're replying to, and the submission arrives in the
220
+ inbox already saying so.
221
+
222
+ ## Gotchas
223
+
224
+ - **Two forms, one page:** fine, as long as element ids are unique. The snippet
225
+ above scopes everything to `scribe-form-<collection>`.
226
+ - **`notify:` does not send email.** It's parsed and logged by a console mailer
227
+ only. Don't promise a client notifications (RFC 0009 is the real thing).
228
+ - **Rate limiting is per Site and IP, not per form**, so hammering one form while
229
+ testing will also throttle the others.
230
+ - **An unchecked checkbox in `FormData` is absent, not `false`.** The payload
231
+ loop above avoids this; if you use `FormData`, add a hidden companion input.
@@ -0,0 +1,99 @@
1
+ # Recipe: reading the schema
2
+
3
+ `.scribe.yml` sits in the Site's repo root and is the contract between Scribe and
4
+ the Site: it names the collections, their fields, where content lives, and which
5
+ collections are forms. The admin keeps it current — editors change it through the
6
+ UI and the CLI writes to it — so the committed copy is authoritative at build
7
+ time.
8
+
9
+ **Read it directly. Don't generate a config file from it.** Older sites have a
10
+ `scripts/generate-config.mjs` that flattened the schema into a `site-config.ts`;
11
+ that layer lost field types and could only ever represent one form, which is why
12
+ RFC 0010 drops it.
13
+
14
+ ## A loader
15
+
16
+ ```ts
17
+ // src/lib/scribe.ts
18
+ import { readFileSync } from 'node:fs';
19
+ import { resolve } from 'node:path';
20
+ import { parse } from 'yaml';
21
+
22
+ export interface ScribeField {
23
+ name: string;
24
+ type: 'string' | 'text' | 'number' | 'boolean' | 'date' | 'select' | 'rich-text' | 'image';
25
+ label?: string;
26
+ required?: boolean;
27
+ hidden?: boolean;
28
+ isBody?: boolean;
29
+ options?: Array<string | { value: string; label?: string }>;
30
+ }
31
+
32
+ export interface ScribeCollection {
33
+ name: string;
34
+ path: string;
35
+ format?: 'yaml' | 'yaml-frontmatter';
36
+ label?: string;
37
+ description?: string;
38
+ form?: boolean;
39
+ fields: ScribeField[];
40
+ view?: { primary?: string; sort?: string; fields?: string[] };
41
+ workflow?: { states?: Array<{ name: string; label?: string; kind?: string; hidden?: boolean }> };
42
+ }
43
+
44
+ export interface ScribeSchema {
45
+ site: string;
46
+ siteName?: string;
47
+ turnstileSiteKey?: string;
48
+ collections: ScribeCollection[];
49
+ }
50
+
51
+ let cached: ScribeSchema | undefined;
52
+
53
+ /** Build-time only — reads the repo's own `.scribe.yml`. */
54
+ export function loadScribeSchema(): ScribeSchema {
55
+ // Resolve from the working directory, NOT from `import.meta.url`. Astro
56
+ // bundles this module into `dist/.prerender/chunks/`, so a module-relative
57
+ // path looks for `.scribe.yml` next to the bundle and fails the build with
58
+ // `ENOENT: .../dist/.scribe.yml`.
59
+ cached ??= parse(readFileSync(resolve(process.cwd(), '.scribe.yml'), 'utf-8')) as ScribeSchema;
60
+ return cached;
61
+ }
62
+
63
+ export const formCollections = () => loadScribeSchema().collections.filter((c) => c.form === true);
64
+ export const contentCollections = () => loadScribeSchema().collections.filter((c) => c.form !== true);
65
+ export const collection = (name: string) =>
66
+ loadScribeSchema().collections.find((c) => c.name === name);
67
+ ```
68
+
69
+ Needs `yaml` in the Site's dependencies (a `devDependency` is fine — this only
70
+ runs at build time, and CI installs dev deps before `npm run build`).
71
+
72
+ ## What the keys mean
73
+
74
+ | Key | Meaning |
75
+ |---|---|
76
+ | `site` | the Site slug — what you pass as `?site=` when submitting a form |
77
+ | `siteName` | display name |
78
+ | `turnstileSiteKey` | public Turnstile key for forms |
79
+ | `collections[].path` | where content files live, relative to the repo root |
80
+ | `collections[].format` | `yaml-frontmatter` (markdown with a body) or `yaml` (data only) |
81
+ | `collections[].form` | `true` means submissions, not files — **no content to render** |
82
+ | `fields[].isBody` | this field is the markdown body, not frontmatter |
83
+ | `fields[].hidden` | internal; don't render it, don't put it on a form |
84
+ | `view.primary` / `view.sort` | the editor's preferred title and sort field |
85
+
86
+ A `form: true` collection has **no files in the repo** — its submissions are rows
87
+ in Scribe's database, readable in the admin or via
88
+ `scribe submission list`/`export`. Don't try to render it as content.
89
+
90
+ ## From the CLI instead
91
+
92
+ Useful when you want the schema without a checkout, or to inspect it while
93
+ working:
94
+
95
+ ```
96
+ scribe schema show <site> # readable summary
97
+ scribe schema show <site> rsvps # one collection
98
+ scribe schema show <site> --json # raw, for piping
99
+ ```